mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 17:05:18 +00:00
1ebfbb14eb
#8593 This PR adds a new role `gitops` to Fleet. MDM capabilities for the role coming on a separate PR. We need this merged ASAP so that we can unblock the UI work for this. - [X] Changes file added for user-visible changes in `changes/` or `orbit/changes/`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Documented any API changes (docs/Using-Fleet/REST-API.md or docs/Contributing/API-for-contributors.md) - [X] Documented any permissions changes - ~[ ] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements)~ - ~[ ] Added support on fleet's osquery simulator `cmd/osquery-perf` for new osquery data ingestion features.~ - [X] Added/updated tests - [x] Manual QA for all new/changed functionality - ~For Orbit and Fleet Desktop changes:~ - ~[ ] Manual QA must be performed in the three main OSs, macOS, Windows and Linux.~ - ~[ ] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)).~
1049 lines
31 KiB
Go
1049 lines
31 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"errors"
|
|
"github.com/go-kit/kit/log/level"
|
|
"html/template"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server"
|
|
"github.com/fleetdm/fleet/v4/server/authz"
|
|
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/mail"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
)
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Create User
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type createUserRequest struct {
|
|
fleet.UserPayload
|
|
}
|
|
|
|
type createUserResponse struct {
|
|
User *fleet.User `json:"user,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r createUserResponse) error() error { return r.Err }
|
|
|
|
func createUserEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*createUserRequest)
|
|
user, err := svc.CreateUser(ctx, req.UserPayload)
|
|
if err != nil {
|
|
return createUserResponse{Err: err}, nil
|
|
}
|
|
return createUserResponse{User: user}, nil
|
|
}
|
|
|
|
func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet.User, error) {
|
|
var teams []fleet.UserTeam
|
|
if p.Teams != nil {
|
|
teams = *p.Teams
|
|
}
|
|
if err := svc.authz.Authorize(ctx, &fleet.User{Teams: teams}, fleet.ActionWrite); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := p.VerifyAdminCreate(); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "verify user payload")
|
|
}
|
|
|
|
if invite, err := svc.ds.InviteByEmail(ctx, *p.Email); err == nil && invite != nil {
|
|
return nil, ctxerr.Errorf(ctx, "%s already invited", *p.Email)
|
|
}
|
|
|
|
if p.AdminForcedPasswordReset == nil {
|
|
// By default, force password reset for users created this way.
|
|
p.AdminForcedPasswordReset = ptr.Bool(true)
|
|
}
|
|
|
|
return svc.NewUser(ctx, p)
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Create User From Invite
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
func createUserFromInviteEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*createUserRequest)
|
|
user, err := svc.CreateUserFromInvite(ctx, req.UserPayload)
|
|
if err != nil {
|
|
return createUserResponse{Err: err}, nil
|
|
}
|
|
return createUserResponse{User: user}, nil
|
|
}
|
|
|
|
func (svc *Service) CreateUserFromInvite(ctx context.Context, p fleet.UserPayload) (*fleet.User, error) {
|
|
// skipauth: There is no viewer context at this point. We rely on verifying
|
|
// the invite for authNZ.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
if err := p.VerifyInviteCreate(); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "verify user payload")
|
|
}
|
|
|
|
invite, err := svc.VerifyInvite(ctx, *p.InviteToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// set the payload role property based on an existing invite.
|
|
p.GlobalRole = invite.GlobalRole.Ptr()
|
|
p.Teams = &invite.Teams
|
|
|
|
user, err := svc.NewUser(ctx, p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = svc.ds.DeleteInvite(ctx, invite.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// List Users
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type listUsersRequest struct {
|
|
ListOptions fleet.UserListOptions `url:"user_options"`
|
|
}
|
|
|
|
type listUsersResponse struct {
|
|
Users []fleet.User `json:"users"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r listUsersResponse) error() error { return r.Err }
|
|
|
|
func listUsersEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*listUsersRequest)
|
|
users, err := svc.ListUsers(ctx, req.ListOptions)
|
|
if err != nil {
|
|
return listUsersResponse{Err: err}, nil
|
|
}
|
|
|
|
resp := listUsersResponse{Users: []fleet.User{}}
|
|
for _, user := range users {
|
|
resp.Users = append(resp.Users, *user)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (svc *Service) ListUsers(ctx context.Context, opt fleet.UserListOptions) ([]*fleet.User, error) {
|
|
user := &fleet.User{}
|
|
if opt.TeamID != 0 {
|
|
user.Teams = []fleet.UserTeam{{Team: fleet.Team{ID: opt.TeamID}}}
|
|
}
|
|
if err := svc.authz.Authorize(ctx, user, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return svc.ds.ListUsers(ctx, opt)
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Me (get own current user)
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
func meEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
user, err := svc.AuthenticatedUser(ctx)
|
|
if err != nil {
|
|
return getUserResponse{Err: err}, nil
|
|
}
|
|
availableTeams, err := svc.ListAvailableTeamsForUser(ctx, user)
|
|
if err != nil {
|
|
if errors.Is(err, fleet.ErrMissingLicense) {
|
|
availableTeams = []*fleet.TeamSummary{}
|
|
} else {
|
|
return getUserResponse{Err: err}, nil
|
|
}
|
|
}
|
|
return getUserResponse{User: user, AvailableTeams: availableTeams}, nil
|
|
}
|
|
|
|
func (svc *Service) AuthenticatedUser(ctx context.Context) (*fleet.User, error) {
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, fleet.ErrNoContext
|
|
}
|
|
|
|
if err := svc.authz.Authorize(ctx, &fleet.User{ID: vc.UserID()}, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !vc.IsLoggedIn() {
|
|
return nil, fleet.NewPermissionError("not logged in")
|
|
}
|
|
return vc.User, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get User
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getUserRequest struct {
|
|
ID uint `url:"id"`
|
|
}
|
|
|
|
type getUserResponse struct {
|
|
User *fleet.User `json:"user,omitempty"`
|
|
AvailableTeams []*fleet.TeamSummary `json:"available_teams"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getUserResponse) error() error { return r.Err }
|
|
|
|
func getUserEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*getUserRequest)
|
|
user, err := svc.User(ctx, req.ID)
|
|
if err != nil {
|
|
return getUserResponse{Err: err}, nil
|
|
}
|
|
availableTeams, err := svc.ListAvailableTeamsForUser(ctx, user)
|
|
if err != nil {
|
|
if errors.Is(err, fleet.ErrMissingLicense) {
|
|
availableTeams = []*fleet.TeamSummary{}
|
|
} else {
|
|
return getUserResponse{Err: err}, nil
|
|
}
|
|
}
|
|
return getUserResponse{User: user, AvailableTeams: availableTeams}, nil
|
|
}
|
|
|
|
// setAuthCheckedOnPreAuthErr can be used to set the authentication as checked
|
|
// in case of errors that happened before an auth check can be performed.
|
|
// Otherwise the endpoints return a "authentication skipped" error instead of
|
|
// the actual returned error.
|
|
func setAuthCheckedOnPreAuthErr(ctx context.Context) {
|
|
if az, ok := authz_ctx.FromContext(ctx); ok {
|
|
az.SetChecked()
|
|
}
|
|
}
|
|
|
|
func (svc *Service) User(ctx context.Context, id uint) (*fleet.User, error) {
|
|
user, err := svc.ds.UserByID(ctx, id)
|
|
if err != nil {
|
|
setAuthCheckedOnPreAuthErr(ctx)
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
if err := svc.authz.Authorize(ctx, user, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Modify User
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type modifyUserRequest struct {
|
|
ID uint `json:"-" url:"id"`
|
|
fleet.UserPayload
|
|
}
|
|
|
|
type modifyUserResponse struct {
|
|
User *fleet.User `json:"user,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r modifyUserResponse) error() error { return r.Err }
|
|
|
|
func modifyUserEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*modifyUserRequest)
|
|
user, err := svc.ModifyUser(ctx, req.ID, req.UserPayload)
|
|
if err != nil {
|
|
return modifyUserResponse{Err: err}, nil
|
|
}
|
|
|
|
return modifyUserResponse{User: user}, nil
|
|
}
|
|
|
|
func (svc *Service) ModifyUser(ctx context.Context, userID uint, p fleet.UserPayload) (*fleet.User, error) {
|
|
user, err := svc.User(ctx, userID)
|
|
if err != nil {
|
|
setAuthCheckedOnPreAuthErr(ctx)
|
|
return nil, err
|
|
}
|
|
|
|
oldGlobalRole := user.GlobalRole
|
|
oldTeams := user.Teams
|
|
|
|
if err := svc.authz.Authorize(ctx, user, fleet.ActionWrite); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, ctxerr.New(ctx, "viewer not present") // should never happen, authorize would've failed
|
|
}
|
|
ownUser := vc.UserID() == userID
|
|
if err := p.VerifyModify(ownUser); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "verify user payload")
|
|
}
|
|
|
|
if p.GlobalRole != nil || p.Teams != nil {
|
|
if err := svc.authz.Authorize(ctx, user, fleet.ActionWriteRole); err != nil {
|
|
return nil, err
|
|
}
|
|
license, _ := license.FromContext(ctx)
|
|
if license == nil {
|
|
return nil, ctxerr.New(ctx, "license not found")
|
|
}
|
|
if err := fleet.ValidateUserRoles(false, p, *license); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "validate role")
|
|
}
|
|
}
|
|
|
|
if p.NewPassword != nil {
|
|
if err := svc.authz.Authorize(ctx, user, fleet.ActionChangePassword); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := fleet.ValidatePasswordRequirements(*p.NewPassword); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("new_password", err.Error()))
|
|
}
|
|
if ownUser {
|
|
// when changing one's own password, user cannot reuse the same password
|
|
// and the old password must be provided (validated by p.VerifyModify above)
|
|
// and must be valid. If changed by admin, then this is not required.
|
|
if err := vc.User.ValidatePassword(*p.NewPassword); err == nil {
|
|
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("new_password", "Cannot reuse old password"))
|
|
}
|
|
if err := vc.User.ValidatePassword(*p.Password); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, fleet.NewPermissionError("incorrect password"))
|
|
}
|
|
}
|
|
}
|
|
|
|
if p.Name != nil {
|
|
user.Name = *p.Name
|
|
}
|
|
|
|
if p.Email != nil && *p.Email != user.Email {
|
|
err = svc.modifyEmailAddress(ctx, user, *p.Email, p.Password)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if p.Position != nil {
|
|
user.Position = *p.Position
|
|
}
|
|
|
|
if p.GravatarURL != nil {
|
|
user.GravatarURL = *p.GravatarURL
|
|
}
|
|
|
|
if p.SSOEnabled != nil {
|
|
user.SSOEnabled = *p.SSOEnabled
|
|
}
|
|
|
|
currentUser := authz.UserFromContext(ctx)
|
|
|
|
if p.GlobalRole != nil && *p.GlobalRole != "" {
|
|
if currentUser.GlobalRole == nil {
|
|
return nil, authz.ForbiddenWithInternal(
|
|
"cannot edit global role as a team member",
|
|
currentUser, user, fleet.ActionWriteRole,
|
|
)
|
|
}
|
|
|
|
if p.Teams != nil && len(*p.Teams) > 0 {
|
|
return nil, fleet.NewInvalidArgumentError("teams", "may not be specified with global_role")
|
|
}
|
|
user.GlobalRole = p.GlobalRole
|
|
user.Teams = []fleet.UserTeam{}
|
|
} else if p.Teams != nil {
|
|
if !isAdminOfTheModifiedTeams(currentUser, user.Teams, *p.Teams) {
|
|
return nil, authz.ForbiddenWithInternal(
|
|
"cannot modify teams in that way",
|
|
currentUser, user, fleet.ActionWriteRole,
|
|
)
|
|
}
|
|
user.Teams = *p.Teams
|
|
user.GlobalRole = nil
|
|
}
|
|
|
|
if p.NewPassword != nil {
|
|
// setNewPassword takes care of calling saveUser
|
|
err = svc.setNewPassword(ctx, user, *p.NewPassword)
|
|
} else {
|
|
err = svc.saveUser(ctx, user)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// load user again to get team-details like names.
|
|
user, err = svc.User(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
adminUser := authz.UserFromContext(ctx)
|
|
if err := fleet.LogRoleChangeActivities(ctx, svc.ds, adminUser, oldGlobalRole, oldTeams, user); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Delete User
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type deleteUserRequest struct {
|
|
ID uint `url:"id"`
|
|
}
|
|
|
|
type deleteUserResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deleteUserResponse) error() error { return r.Err }
|
|
|
|
func deleteUserEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*deleteUserRequest)
|
|
err := svc.DeleteUser(ctx, req.ID)
|
|
if err != nil {
|
|
return deleteUserResponse{Err: err}, nil
|
|
}
|
|
return deleteUserResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) DeleteUser(ctx context.Context, id uint) error {
|
|
user, err := svc.ds.UserByID(ctx, id)
|
|
if err != nil {
|
|
setAuthCheckedOnPreAuthErr(ctx)
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
if err := svc.authz.Authorize(ctx, user, fleet.ActionWrite); err != nil {
|
|
return err
|
|
}
|
|
if err := svc.ds.DeleteUser(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
|
|
adminUser := authz.UserFromContext(ctx)
|
|
if err := svc.ds.NewActivity(
|
|
ctx,
|
|
adminUser,
|
|
fleet.ActivityTypeDeletedUser{
|
|
UserID: user.ID,
|
|
UserName: user.Name,
|
|
UserEmail: user.Email,
|
|
},
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Require Password Reset
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type requirePasswordResetRequest struct {
|
|
Require bool `json:"require"`
|
|
ID uint `json:"-" url:"id"`
|
|
}
|
|
|
|
type requirePasswordResetResponse struct {
|
|
User *fleet.User `json:"user,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r requirePasswordResetResponse) error() error { return r.Err }
|
|
|
|
func requirePasswordResetEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*requirePasswordResetRequest)
|
|
user, err := svc.RequirePasswordReset(ctx, req.ID, req.Require)
|
|
if err != nil {
|
|
return requirePasswordResetResponse{Err: err}, nil
|
|
}
|
|
return requirePasswordResetResponse{User: user}, nil
|
|
}
|
|
|
|
func (svc *Service) RequirePasswordReset(ctx context.Context, uid uint, require bool) (*fleet.User, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.User{ID: uid}, fleet.ActionWrite); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
user, err := svc.ds.UserByID(ctx, uid)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "loading user by ID")
|
|
}
|
|
if user.SSOEnabled {
|
|
return nil, ctxerr.New(ctx, "password reset for single sign on user not allowed")
|
|
}
|
|
// Require reset on next login
|
|
user.AdminForcedPasswordReset = require
|
|
if err := svc.saveUser(ctx, user); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "saving user")
|
|
}
|
|
|
|
if require {
|
|
// Clear all of the existing sessions
|
|
if err := svc.DeleteSessionsForUser(ctx, user.ID); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "deleting user sessions")
|
|
}
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Change Password
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type changePasswordRequest struct {
|
|
OldPassword string `json:"old_password"`
|
|
NewPassword string `json:"new_password"`
|
|
}
|
|
|
|
type changePasswordResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r changePasswordResponse) error() error { return r.Err }
|
|
|
|
func changePasswordEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*changePasswordRequest)
|
|
err := svc.ChangePassword(ctx, req.OldPassword, req.NewPassword)
|
|
return changePasswordResponse{Err: err}, nil
|
|
}
|
|
|
|
func (svc *Service) ChangePassword(ctx context.Context, oldPass, newPass string) error {
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return fleet.ErrNoContext
|
|
}
|
|
|
|
if err := svc.authz.Authorize(ctx, vc.User, fleet.ActionChangePassword); err != nil {
|
|
return err
|
|
}
|
|
|
|
if oldPass == "" {
|
|
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("old_password", "Old password cannot be empty"))
|
|
}
|
|
if newPass == "" {
|
|
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("new_password", "New password cannot be empty"))
|
|
}
|
|
if err := fleet.ValidatePasswordRequirements(newPass); err != nil {
|
|
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("new_password", err.Error()))
|
|
}
|
|
if vc.User.SSOEnabled {
|
|
return ctxerr.New(ctx, "change password for single sign on user not allowed")
|
|
}
|
|
if err := vc.User.ValidatePassword(newPass); err == nil {
|
|
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("new_password", "Cannot reuse old password"))
|
|
}
|
|
if err := vc.User.ValidatePassword(oldPass); err != nil {
|
|
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("old_password", "old password does not match"))
|
|
}
|
|
|
|
if err := svc.setNewPassword(ctx, vc.User, newPass); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "setting new password")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get Info About Sessions For User
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getInfoAboutSessionsForUserRequest struct {
|
|
ID uint `url:"id"`
|
|
}
|
|
|
|
type getInfoAboutSessionsForUserResponse struct {
|
|
Sessions []getInfoAboutSessionResponse `json:"sessions"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getInfoAboutSessionsForUserResponse) error() error { return r.Err }
|
|
|
|
func getInfoAboutSessionsForUserEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*getInfoAboutSessionsForUserRequest)
|
|
sessions, err := svc.GetInfoAboutSessionsForUser(ctx, req.ID)
|
|
if err != nil {
|
|
return getInfoAboutSessionsForUserResponse{Err: err}, nil
|
|
}
|
|
var resp getInfoAboutSessionsForUserResponse
|
|
for _, session := range sessions {
|
|
resp.Sessions = append(resp.Sessions, getInfoAboutSessionResponse{
|
|
SessionID: session.ID,
|
|
UserID: session.UserID,
|
|
CreatedAt: session.CreatedAt,
|
|
})
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (svc *Service) GetInfoAboutSessionsForUser(ctx context.Context, id uint) ([]*fleet.Session, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Session{UserID: id}, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var validatedSessions []*fleet.Session
|
|
|
|
sessions, err := svc.ds.ListSessionsForUser(ctx, id)
|
|
if err != nil {
|
|
return validatedSessions, err
|
|
}
|
|
|
|
for _, session := range sessions {
|
|
if svc.validateSession(ctx, session) == nil {
|
|
validatedSessions = append(validatedSessions, session)
|
|
}
|
|
}
|
|
|
|
return validatedSessions, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Delete Sessions For User
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type deleteSessionsForUserRequest struct {
|
|
ID uint `url:"id"`
|
|
}
|
|
|
|
type deleteSessionsForUserResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deleteSessionsForUserResponse) error() error { return r.Err }
|
|
|
|
func deleteSessionsForUserEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*deleteSessionsForUserRequest)
|
|
err := svc.DeleteSessionsForUser(ctx, req.ID)
|
|
if err != nil {
|
|
return deleteSessionsForUserResponse{Err: err}, nil
|
|
}
|
|
return deleteSessionsForUserResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) DeleteSessionsForUser(ctx context.Context, id uint) error {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Session{UserID: id}, fleet.ActionWrite); err != nil {
|
|
return err
|
|
}
|
|
|
|
return svc.ds.DestroyAllSessionsForUser(ctx, id)
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Change user email
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type changeEmailRequest struct {
|
|
Token string `url:"token"`
|
|
}
|
|
|
|
type changeEmailResponse struct {
|
|
NewEmail string `json:"new_email"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r changeEmailResponse) error() error { return r.Err }
|
|
|
|
func changeEmailEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*changeEmailRequest)
|
|
newEmailAddress, err := svc.ChangeUserEmail(ctx, req.Token)
|
|
if err != nil {
|
|
return changeEmailResponse{Err: err}, nil
|
|
}
|
|
return changeEmailResponse{NewEmail: newEmailAddress}, nil
|
|
}
|
|
|
|
func (svc *Service) ChangeUserEmail(ctx context.Context, token string) (string, error) {
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return "", fleet.ErrNoContext
|
|
}
|
|
|
|
if err := svc.authz.Authorize(ctx, &fleet.User{ID: vc.UserID()}, fleet.ActionWrite); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return svc.ds.ConfirmPendingEmailChange(ctx, vc.UserID(), token)
|
|
}
|
|
|
|
// isAdminOfTheModifiedTeams checks whether the current user is allowed to modify the user
|
|
// roles in the teams.
|
|
//
|
|
// TODO: End-goal is to move all this logic to policy.rego.
|
|
func isAdminOfTheModifiedTeams(currentUser *fleet.User, originalUserTeams, newUserTeams []fleet.UserTeam) bool {
|
|
// Global admins can modify all user teams roles.
|
|
if currentUser.GlobalRole != nil && *currentUser.GlobalRole == fleet.RoleAdmin {
|
|
return true
|
|
}
|
|
|
|
// Otherwise, make a map of the original and resulting teams.
|
|
newTeams := make(map[uint]string)
|
|
for _, team := range newUserTeams {
|
|
newTeams[team.ID] = team.Role
|
|
}
|
|
originalTeams := make(map[uint]struct{})
|
|
for _, team := range originalUserTeams {
|
|
originalTeams[team.ID] = struct{}{}
|
|
}
|
|
|
|
// See which ones were removed or changed from the original.
|
|
teamsAffected := make(map[uint]struct{})
|
|
for _, team := range originalUserTeams {
|
|
if newTeams[team.ID] != team.Role {
|
|
teamsAffected[team.ID] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// See which ones of the new are not in the original.
|
|
for _, team := range newUserTeams {
|
|
if _, ok := originalTeams[team.ID]; !ok {
|
|
teamsAffected[team.ID] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// Then gather the teams the current user is admin for.
|
|
currentUserTeamAdmin := make(map[uint]struct{})
|
|
for _, team := range currentUser.Teams {
|
|
if team.Role == fleet.RoleAdmin {
|
|
currentUserTeamAdmin[team.ID] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// And finally, let's check that the teams that were either removed
|
|
// or changed are also teams this user is an admin of.
|
|
for teamID := range teamsAffected {
|
|
if _, ok := currentUserTeamAdmin[teamID]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (svc *Service) modifyEmailAddress(ctx context.Context, user *fleet.User, email string, password *string) error {
|
|
// password requirement handled in validation middleware
|
|
if password != nil {
|
|
err := user.ValidatePassword(*password)
|
|
if err != nil {
|
|
return fleet.NewPermissionError("incorrect password")
|
|
}
|
|
}
|
|
random, err := server.GenerateRandomText(svc.config.App.TokenKeySize)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
token := base64.URLEncoding.EncodeToString([]byte(random))
|
|
|
|
switch _, err = svc.ds.UserByEmail(ctx, email); {
|
|
case err == nil:
|
|
return ctxerr.Wrap(ctx, newAlreadyExistsError())
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
// OK
|
|
default:
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
switch _, err = svc.ds.InviteByEmail(ctx, email); {
|
|
case err == nil:
|
|
return ctxerr.Wrap(ctx, newAlreadyExistsError())
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
// OK
|
|
default:
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
err = svc.ds.PendingEmailChange(ctx, user.ID, email, token)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
config, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
changeEmail := fleet.Email{
|
|
Subject: "Confirm Fleet Email Change",
|
|
To: []string{email},
|
|
Config: config,
|
|
Mailer: &mail.ChangeEmailMailer{
|
|
Token: token,
|
|
BaseURL: template.URL(config.ServerSettings.ServerURL + svc.config.Server.URLPrefix),
|
|
AssetURL: getAssetURL(),
|
|
},
|
|
}
|
|
return svc.mailService.SendEmail(changeEmail)
|
|
}
|
|
|
|
// saves user in datastore.
|
|
// doesn't need to be exposed to the transport
|
|
// the service should expose actions for modifying a user instead
|
|
func (svc *Service) saveUser(ctx context.Context, user *fleet.User) error {
|
|
return svc.ds.SaveUser(ctx, user)
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Perform Required Password Reset
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type performRequiredPasswordResetRequest struct {
|
|
Password string `json:"new_password"`
|
|
ID uint `json:"id"`
|
|
}
|
|
|
|
type performRequiredPasswordResetResponse struct {
|
|
User *fleet.User `json:"user,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r performRequiredPasswordResetResponse) error() error { return r.Err }
|
|
|
|
func performRequiredPasswordResetEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*performRequiredPasswordResetRequest)
|
|
user, err := svc.PerformRequiredPasswordReset(ctx, req.Password)
|
|
if err != nil {
|
|
return performRequiredPasswordResetResponse{Err: err}, nil
|
|
}
|
|
return performRequiredPasswordResetResponse{User: user}, nil
|
|
}
|
|
|
|
func (svc *Service) PerformRequiredPasswordReset(ctx context.Context, password string) (*fleet.User, error) {
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, fleet.ErrNoContext
|
|
}
|
|
if !vc.CanPerformPasswordReset() {
|
|
return nil, fleet.NewPermissionError("cannot reset password")
|
|
}
|
|
user := vc.User
|
|
|
|
if err := svc.authz.Authorize(ctx, user, fleet.ActionChangePassword); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if user.SSOEnabled {
|
|
return nil, ctxerr.New(ctx, "password reset for single sign on user not allowed")
|
|
}
|
|
if !user.IsAdminForcedPasswordReset() {
|
|
return nil, ctxerr.New(ctx, "user does not require password reset")
|
|
}
|
|
|
|
// prevent setting the same password
|
|
if err := user.ValidatePassword(password); err == nil {
|
|
return nil, fleet.NewInvalidArgumentError("new_password", "Cannot reuse old password")
|
|
}
|
|
|
|
if err := fleet.ValidatePasswordRequirements(password); err != nil {
|
|
return nil, fleet.NewInvalidArgumentError("new_password", "Password does not meet required criteria")
|
|
}
|
|
|
|
user.AdminForcedPasswordReset = false
|
|
err := svc.setNewPassword(ctx, user, password)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "setting new password")
|
|
}
|
|
|
|
// Sessions should already have been cleared when the reset was
|
|
// required
|
|
|
|
return user, nil
|
|
}
|
|
|
|
// setNewPassword is a helper for changing a user's password. It should be
|
|
// called to set the new password after proper authorization has been
|
|
// performed.
|
|
func (svc *Service) setNewPassword(ctx context.Context, user *fleet.User, password string) error {
|
|
err := user.SetPassword(password, svc.config.Auth.SaltKeySize, svc.config.Auth.BcryptCost)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "setting new password")
|
|
}
|
|
if user.SSOEnabled {
|
|
return ctxerr.New(ctx, "set password for single sign on user not allowed")
|
|
}
|
|
err = svc.saveUser(ctx, user)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "saving changed password")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Reset Password
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type resetPasswordRequest struct {
|
|
PasswordResetToken string `json:"password_reset_token"`
|
|
NewPassword string `json:"new_password"`
|
|
}
|
|
|
|
type resetPasswordResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r resetPasswordResponse) error() error { return r.Err }
|
|
|
|
func resetPasswordEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*resetPasswordRequest)
|
|
err := svc.ResetPassword(ctx, req.PasswordResetToken, req.NewPassword)
|
|
return resetPasswordResponse{Err: err}, nil
|
|
}
|
|
|
|
func (svc *Service) ResetPassword(ctx context.Context, token, password string) error {
|
|
// skipauth: No viewer context available. The user is locked out of their
|
|
// account and authNZ is performed entirely by providing a valid password
|
|
// reset token.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
if token == "" {
|
|
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Token cannot be empty field"))
|
|
}
|
|
if password == "" {
|
|
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("new_password", "New password cannot be empty field"))
|
|
}
|
|
if err := fleet.ValidatePasswordRequirements(password); err != nil {
|
|
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("new_password", err.Error()))
|
|
}
|
|
|
|
reset, err := svc.ds.FindPasswordResetByToken(ctx, token)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, fleet.NewAuthFailedError(err.Error()), "find password reset request by token")
|
|
}
|
|
user, err := svc.ds.UserByID(ctx, reset.UserID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, fleet.NewAuthFailedError(err.Error()), "find user by id")
|
|
}
|
|
|
|
if user.SSOEnabled {
|
|
return ctxerr.New(ctx, "password reset for single sign on user not allowed")
|
|
}
|
|
|
|
// prevent setting the same password
|
|
if err := user.ValidatePassword(password); err == nil {
|
|
return fleet.NewInvalidArgumentError("new_password", "Cannot reuse old password")
|
|
}
|
|
|
|
// password requirements are validated as part of `setNewPassword``
|
|
err = svc.setNewPassword(ctx, user, password)
|
|
if err != nil {
|
|
return fleet.NewInvalidArgumentError("new_password", err.Error())
|
|
}
|
|
|
|
// delete password reset tokens for user
|
|
if err := svc.ds.DeletePasswordResetRequestsForUser(ctx, user.ID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "delete password reset requests")
|
|
}
|
|
|
|
// Clear sessions so that any other browsers will have to log in with
|
|
// the new password
|
|
if err := svc.ds.DestroyAllSessionsForUser(ctx, user.ID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "delete user sessions")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Forgot Password
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type forgotPasswordRequest struct {
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
type forgotPasswordResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r forgotPasswordResponse) error() error { return r.Err }
|
|
func (r forgotPasswordResponse) Status() int { return http.StatusAccepted }
|
|
|
|
func forgotPasswordEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
|
req := request.(*forgotPasswordRequest)
|
|
// Any error returned by the service should not be returned to the
|
|
// client to prevent information disclosure (it will be logged in the
|
|
// server logs).
|
|
_ = svc.RequestPasswordReset(ctx, req.Email)
|
|
return forgotPasswordResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) RequestPasswordReset(ctx context.Context, email string) error {
|
|
// skipauth: No viewer context available. The user is locked out of their
|
|
// account and trying to reset their password.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
// Regardless of error, sleep until the request has taken at least 1 second.
|
|
// This means that any request to this method will take ~1s and frustrate a timing attack.
|
|
defer func(start time.Time) {
|
|
time.Sleep(time.Until(start.Add(1 * time.Second)))
|
|
}(time.Now())
|
|
|
|
user, err := svc.ds.UserByEmail(ctx, email)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if user.SSOEnabled {
|
|
return ctxerr.New(ctx, "password reset for single sign on user not allowed")
|
|
}
|
|
|
|
random, err := server.GenerateRandomText(svc.config.App.TokenKeySize)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
token := base64.URLEncoding.EncodeToString([]byte(random))
|
|
|
|
request := &fleet.PasswordResetRequest{
|
|
UserID: user.ID,
|
|
Token: token,
|
|
}
|
|
_, err = svc.ds.NewPasswordResetRequest(ctx, request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
config, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resetEmail := fleet.Email{
|
|
Subject: "Reset Your Fleet Password",
|
|
To: []string{user.Email},
|
|
Config: config,
|
|
Mailer: &mail.PasswordResetMailer{
|
|
BaseURL: template.URL(config.ServerSettings.ServerURL + svc.config.Server.URLPrefix),
|
|
AssetURL: getAssetURL(),
|
|
Token: token,
|
|
},
|
|
}
|
|
|
|
err = svc.mailService.SendEmail(resetEmail)
|
|
if err != nil {
|
|
level.Error(svc.logger).Log("err", err, "msg", "failed to send password reset request email")
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (svc *Service) ListAvailableTeamsForUser(ctx context.Context, user *fleet.User) ([]*fleet.TeamSummary, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|