fleet/server/service/users.go
Victor Lyuboslavsky 8e68173663
Added UserSummary type for UsersByIDs. (#38710)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #38234

Addresses Ian's suggestion from activity bounded context code review.

# Checklist for submitter

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Updated user lookup functionality across the system to return minimal
user information instead of full user objects. Changes affect multiple
system interfaces and data access layers to optimize performance and
reduce data payload for user-related operations throughout the
application.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-23 15:06:52 -06:00

1264 lines
38 KiB
Go

package service
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"html/template"
"net/http"
"time"
"github.com/go-kit/log/level"
"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/ctxdb"
"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"`
// Token is only returned when creating API-only (non-SSO) users.
Token *string `json:"token,omitempty"`
Err error `json:"error,omitempty"`
}
func (r createUserResponse) Error() error { return r.Err }
func createUserEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*createUserRequest)
user, sessionKey, err := svc.CreateUser(ctx, req.UserPayload)
if err != nil {
return createUserResponse{Err: err}, nil
}
return createUserResponse{
User: user,
Token: sessionKey,
}, nil
}
var errMailerRequiredForMFA = badRequest("Email must be set up to enable Fleet MFA")
func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet.User, *string, 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, nil, err
}
if err := p.VerifyAdminCreate(); err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "verify user payload")
}
if teams != nil {
// Validate that the teams exist
teamsSummary, err := svc.ds.TeamsSummary(ctx)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "fetching teams in attempt to verify team exists")
}
teamIDs := map[uint]struct{}{}
for _, team := range teamsSummary {
teamIDs[team.ID] = struct{}{}
}
for _, userTeam := range teams {
_, ok := teamIDs[userTeam.Team.ID]
if !ok {
return nil, nil, ctxerr.Wrap(
ctx, fleet.NewInvalidArgumentError("teams.id", fmt.Sprintf("team with id %d does not exist", userTeam.Team.ID)),
)
}
}
}
if invite, err := svc.ds.InviteByEmail(ctx, *p.Email); err == nil && invite != nil {
return nil, 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)
}
// make sure we can send email before requiring email sending to log in
if p.MFAEnabled != nil && *p.MFAEnabled {
config, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, nil, err
}
var smtpSettings fleet.SMTPSettings
if config.SMTPSettings != nil {
smtpSettings = *config.SMTPSettings
}
if !svc.mailService.CanSendEmail(smtpSettings) {
return nil, nil, errMailerRequiredForMFA
}
}
user, err := svc.NewUser(ctx, p)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "create user")
}
// The sessionKey is returned for API-only non-SSO users only.
var sessionKey *string
if user.APIOnly && !user.SSOEnabled {
if p.Password == nil {
// Should not happen but let's log just in case.
level.Error(svc.logger).Log("err", err, "msg", "password not set during admin user creation")
} else {
// Create a session for the API-only user by logging in.
_, session, err := svc.Login(ctx, user.Email, *p.Password, false)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "create session for api-only user")
}
sessionKey = &session.Key
}
}
return user, sessionKey, nil
}
////////////////////////////////////////////////////////////////////////////////
// Create User From Invite
////////////////////////////////////////////////////////////////////////////////
func createUserFromInviteEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.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
p.MFAEnabled = ptr.Bool(invite.MFAEnabled)
// Invite ID is only used as a uniq index to prevent a double invite acceptance race condition
p.InviteID = &invite.ID
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) (fleet.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)
}
func (svc *Service) UsersByIDs(ctx context.Context, ids []uint) ([]*fleet.UserSummary, error) {
// Authorize read access to users (no specific team context)
if err := svc.authz.Authorize(ctx, &fleet.User{}, fleet.ActionRead); err != nil {
return nil, err
}
return svc.ds.UsersByIDs(ctx, ids)
}
// //////////////////////////////////////////////////////////////////////////////
// Me (get own current user)
// //////////////////////////////////////////////////////////////////////////////
type getMeRequest struct {
IncludeUISettings bool `query:"include_ui_settings,optional"`
}
func meEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.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
}
}
req := request.(*getMeRequest)
var userSettings *fleet.UserSettings
if req.IncludeUISettings {
userSettings, err = svc.GetUserSettings(ctx, user.ID)
if err != nil {
return getUserResponse{Err: err}, nil
}
}
return getUserResponse{User: user, AvailableTeams: availableTeams, Settings: userSettings}, 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"`
IncludeUISettings bool `query:"include_ui_settings,optional"`
}
type getUserResponse struct {
User *fleet.User `json:"user,omitempty"`
AvailableTeams []*fleet.TeamSummary `json:"available_teams"`
Settings *fleet.UserSettings `json:"settings,omitempty"`
Err error `json:"error,omitempty"`
}
func (r getUserResponse) Error() error { return r.Err }
func getUserEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.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
}
}
var userSettings *fleet.UserSettings
if req.IncludeUISettings {
userSettings, err = svc.GetUserSettings(ctx, user.ID)
if err != nil {
return getUserResponse{Err: err}, nil
}
}
return getUserResponse{User: user, AvailableTeams: availableTeams, Settings: userSettings}, nil
}
func (svc *Service) GetUserSettings(ctx context.Context, userID uint) (*fleet.UserSettings, error) {
if err := svc.authz.Authorize(ctx, &fleet.User{ID: userID}, fleet.ActionRead); err != nil {
return nil, err
}
return svc.ds.UserSettings(ctx, userID)
}
// 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) (fleet.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.MFAEnabled != nil {
if *p.MFAEnabled && !user.MFAEnabled {
lic, _ := license.FromContext(ctx)
if lic == nil {
return nil, ctxerr.New(ctx, "license not found")
}
if !lic.IsPremium() {
return nil, fleet.ErrMissingLicense
}
if (p.SSOEnabled != nil && *p.SSOEnabled) || (p.SSOEnabled == nil && user.SSOEnabled) {
return nil, SSOMFAConflict
}
// make sure we can send email before requiring email sending to log in
config, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, err
}
var smtpSettings fleet.SMTPSettings
if config.SMTPSettings != nil {
smtpSettings = *config.SMTPSettings
}
if !svc.mailService.CanSendEmail(smtpSettings) {
return nil, errMailerRequiredForMFA
}
}
user.MFAEnabled = *p.MFAEnabled
}
if (p.SSOEnabled != nil && *p.SSOEnabled) && user.MFAEnabled {
return nil, SSOMFAConflict
}
if p.GlobalRole != nil || p.Teams != nil {
if err := svc.authz.Authorize(ctx, user, fleet.ActionWriteRole); err != nil {
return nil, err
}
licChecker, _ := license.FromContext(ctx)
lic, _ := licChecker.(*fleet.LicenseInfo)
if lic == nil {
return nil, ctxerr.New(ctx, "license not found")
}
if err := fleet.ValidateUserRoles(false, p, *lic); 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 {
if !*p.SSOEnabled && user.SSOEnabled && p.NewPassword == nil {
return nil, fleet.NewInvalidArgumentError("missing password", "a new password must be provided when disabling SSO")
}
user.SSOEnabled = *p.SSOEnabled
}
if p.Settings != nil {
user.Settings = p.Settings
}
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")
}
// Check if demoting from admin - ensure we're not demoting the last one
if user.GlobalRole != nil && *user.GlobalRole == fleet.RoleAdmin {
if *p.GlobalRole != fleet.RoleAdmin {
count, err := svc.ds.CountGlobalAdmins(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "count global admins")
}
if count <= 1 {
return nil, fleet.NewInvalidArgumentError("global_role", "cannot demote the last global admin")
}
}
}
user.GlobalRole = p.GlobalRole
user.Teams = []fleet.UserTeam{}
} else if p.Teams != nil {
// Check if demoting from admin by assigning teams (which removes global role)
if user.GlobalRole != nil && *user.GlobalRole == fleet.RoleAdmin {
count, err := svc.ds.CountGlobalAdmins(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "count global admins")
}
if count <= 1 {
return nil, fleet.NewInvalidArgumentError("teams", "cannot demote the last global admin")
}
}
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.
// Since we just modified the user and the changes may not have replicated to the read replica(s) yet,
// we must use the master to ensure we get the most up-to-date information.
ctxUsePrimary := ctxdb.RequirePrimary(ctx, true)
user, err = svc.User(ctxUsePrimary, userID)
if err != nil {
return nil, err
}
adminUser := authz.UserFromContext(ctx)
if err := fleet.LogRoleChangeActivities(ctx, svc, 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) (fleet.Errorer, error) {
req := request.(*deleteUserRequest)
if _, err := svc.DeleteUser(ctx, req.ID); err != nil {
return deleteUserResponse{Err: err}, nil
}
return deleteUserResponse{}, nil
}
func (svc *Service) DeleteUser(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.ActionWrite); err != nil {
return nil, err
}
// prevent deleting admin if they are the last one
if user.GlobalRole != nil && *user.GlobalRole == fleet.RoleAdmin {
count, err := svc.ds.CountGlobalAdmins(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "count global admins")
}
if count <= 1 {
return nil, fleet.NewInvalidArgumentError("id", "cannot delete the last global admin")
}
}
if err := svc.ds.DeleteUser(ctx, id); err != nil {
return nil, err
}
adminUser := authz.UserFromContext(ctx)
if err := svc.NewActivity(
ctx,
adminUser,
fleet.ActivityTypeDeletedUser{
UserID: user.ID,
UserName: user.Name,
UserEmail: user.Email,
},
); err != nil {
return nil, err
}
return user, 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) (fleet.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) (fleet.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) (fleet.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) (fleet.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) (fleet.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
}
var smtpSettings fleet.SMTPSettings
if config.SMTPSettings != nil {
smtpSettings = *config.SMTPSettings
}
changeEmail := fleet.Email{
Subject: "Confirm Fleet Email Change",
To: []string{email},
SMTPSettings: smtpSettings,
ServerURL: config.ServerSettings.ServerURL,
Mailer: &mail.ChangeEmailMailer{
Token: token,
BaseURL: template.URL(config.ServerSettings.ServerURL + svc.config.Server.URLPrefix),
AssetURL: getAssetURL(),
},
}
return svc.mailService.SendEmail(ctx, 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) (fleet.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 {
// No user in the context -- authentication issue
svc.authz.SkipAuthorization(ctx)
return nil, authz.ForbiddenWithInternal("No user in the context", nil, nil, nil)
}
if !vc.CanPerformPasswordReset() {
svc.authz.SkipAuthorization(ctx)
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 {
// should never happen because this would get caught by the
// CanPerformPasswordReset check above
err := fleet.NewPermissionError("password reset for single sign on user not allowed")
return nil, ctxerr.Wrap(ctx, err)
}
if !user.IsAdminForcedPasswordReset() {
// should never happen because this would get caught by the
// CanPerformPasswordReset check above
err := fleet.NewPermissionError("cannot reset password")
return nil, ctxerr.Wrap(ctx, err)
}
// 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: Must include 12 characters, at least 1 number (e.g. 0 - 9), and at least 1 symbol (e.g. &*#).")
}
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) (fleet.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) (fleet.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).
if err := svc.RequestPasswordReset(ctx, req.Email); errors.Is(err, fleet.ErrPasswordResetNotConfigured) {
return forgotPasswordResponse{Err: err}, nil
}
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())
config, err := svc.ds.AppConfig(ctx)
if err != nil {
return err
}
if !svc.mailService.CanSendEmail(*config.SMTPSettings) {
return fleet.ErrPasswordResetNotConfigured
}
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
}
var smtpSettings fleet.SMTPSettings
if config.SMTPSettings != nil {
smtpSettings = *config.SMTPSettings
}
resetEmail := fleet.Email{
Subject: "Reset Your Fleet Password",
To: []string{user.Email},
SMTPSettings: smtpSettings,
ServerURL: config.ServerSettings.ServerURL,
Mailer: &mail.PasswordResetMailer{
BaseURL: template.URL(config.ServerSettings.ServerURL + svc.config.Server.URLPrefix),
AssetURL: getAssetURL(),
Token: token,
},
}
err = svc.mailService.SendEmail(ctx, 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
}