Files
fleet/server/fleet/users.go
T
Magnus Jensen b9136f4da5 Release from AB backend support (#49680)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #49367 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually

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

* **New Features**
* Added “Release from Apple Business” for eligible Apple hosts,
including per-device success/failure reporting and activity logging.
* Added a new API endpoint to trigger the action and return results for
each selected host with clear error details.
* Introduced authorization rules for global admins and team admins to
release only within allowed scope.
* **Bug Fixes**
* Improved validation and error handling: rejects oversized selections,
reports unknown/ineligible hosts and DEP-related failures per device,
and treats assignment-cleanup failures as non-blocking.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-22 20:01:41 +02:00

553 lines
16 KiB
Go

package fleet
import (
"encoding/json"
"errors"
"fmt"
"net/mail"
"time"
"unicode"
"github.com/fleetdm/fleet/v4/server"
"golang.org/x/crypto/bcrypt"
)
// ErrLastGlobalAdmin is returned when an operation would remove the last global admin.
var ErrLastGlobalAdmin = errors.New("cannot remove the last global admin")
// UserSummary contains the minimal user fields.
type UserSummary struct {
ID uint `db:"id"`
Name string `db:"name"`
Email string `db:"email"`
GravatarURL string `db:"gravatar_url"`
APIOnly bool `db:"api_only"`
}
// APIEndpointRef represents an endpoint an API-only user has access to.
type APIEndpointRef struct {
Method string `json:"method"`
Path string `json:"path"`
}
// OptionalAPIEndpoints is a JSON-nullable field that distinguishes three states
// when decoding a PATCH request body:
//
// - Field absent → Present=false: no change to the user's current endpoints.
// - Field is null → Present=true, Value=nil: clear all entries (full access).
// - Field is an array → Present=true, Value=[...]: replace with specific entries.
type OptionalAPIEndpoints struct {
Present bool
Value []APIEndpointRef
}
func (o *OptionalAPIEndpoints) UnmarshalJSON(data []byte) error {
o.Present = true
if string(data) == "null" {
o.Value = nil
return nil
}
return json.Unmarshal(data, &o.Value)
}
// User is the model struct that represents a Fleet user.
type User struct {
UpdateCreateTimestamps
ID uint `json:"id"`
Password []byte `json:"-"`
Salt string `json:"-"`
Name string `json:"name"`
Email string `json:"email"`
AdminForcedPasswordReset bool `json:"force_password_reset" db:"admin_forced_password_reset"`
GravatarURL string `json:"gravatar_url" db:"gravatar_url"`
Position string `json:"position,omitempty"` // job role
// SSOEnabled if true, the user may only log in via SSO
SSOEnabled bool `json:"sso_enabled" db:"sso_enabled"`
// MFAEnabled if true, the user (if non-SSO) must click a magic link via email to complete login
MFAEnabled bool `json:"mfa_enabled" db:"mfa_enabled"`
GlobalRole *string `json:"global_role" db:"global_role"`
APIOnly bool `json:"api_only" db:"api_only"`
// Teams is the teams this user has roles in. For users with a global role, Teams is expected to be empty.
Teams []UserTeam `json:"teams" renameto:"fleets"`
// Only used to to prevent duplicate invite acceptance
InviteID *uint `json:"-" db:"invite_id"`
Settings *UserSettings `json:"settings,omitempty"`
Deleted bool `json:"-" db:"deleted"`
// APIEndpoints if this user is an API-only user, this returns
// a list of all end-points the user has access to.
APIEndpoints []APIEndpointRef `json:"api_endpoints,omitempty"`
}
type UserSettings struct {
HiddenHostColumns []string `json:"hidden_host_columns"`
}
// Scan implements the sql.Scanner interface, tells DB driver how to convert MySQL type (json) to
// custom Go type (UserSettings).
func (us *UserSettings) Scan(val interface{}) error {
switch v := val.(type) {
case []byte:
return json.Unmarshal(v, us)
case string:
return json.Unmarshal([]byte(v), us)
default:
return fmt.Errorf("unsupported type: %T", v)
}
}
// IsGlobalObserver returns true if user is either a Global Observer or a Global Observer+
func (u *User) IsGlobalObserver() bool {
if u.GlobalRole == nil {
return false
}
return *u.GlobalRole == RoleObserver || *u.GlobalRole == RoleObserverPlus
}
// TeamMembership returns a map whose keys are the TeamIDs of the teams for which pred evaluates to true
func (u *User) TeamMembership(pred func(UserTeam) bool) map[uint]bool {
result := make(map[uint]bool)
for _, t := range u.Teams {
if pred(t) {
result[t.ID] = true
}
}
return result
}
func (u *User) IsAdminForcedPasswordReset() bool {
if u.SSOEnabled {
return false
}
return u.AdminForcedPasswordReset
}
// IsAnyAdmin checks if the user is either Global Admin or Admin on any team.
func (u *User) IsAnyAdmin() bool {
if u.GlobalRole != nil && *u.GlobalRole == RoleAdmin {
return true
}
for _, t := range u.Teams {
if t.IsAdmin() {
return true
}
}
return false
}
func (u *User) AuthzType() string {
return "user"
}
type UserTeam struct {
// Team is the team object.
Team
// Role is the role the user has for the team.
Role string `json:"role" db:"role"`
}
func (u UserTeam) IsAdmin() bool {
return u.Role == RoleAdmin
}
func (u UserTeam) MarshalJSON() ([]byte, error) {
x := struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"created_at"`
Name string `json:"name"`
Description string `json:"description"`
TeamConfig
UserCount int `json:"user_count"`
Users []TeamUser `json:"users,omitempty"`
HostCount int `json:"host_count"`
Hosts []HostResponse `json:"hosts,omitempty"`
Secrets []*EnrollSecret `json:"secrets,omitempty"`
Role string `json:"role"`
}{
ID: u.ID,
CreatedAt: u.CreatedAt,
Name: u.Name,
Description: u.Description,
TeamConfig: u.Config,
UserCount: u.UserCount,
Users: u.Users,
HostCount: u.HostCount,
Hosts: HostResponsesForHostsCheap(u.Hosts),
Secrets: u.Secrets,
Role: u.Role,
}
return json.Marshal(x)
}
func (u *UserTeam) UnmarshalJSON(b []byte) error {
var x struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"created_at"`
Name string `json:"name"`
Description string `json:"description"`
TeamConfig
UserCount int `json:"user_count"`
Users []TeamUser `json:"users,omitempty"`
HostCount int `json:"host_count"`
Hosts []Host `json:"hosts,omitempty"`
Secrets []*EnrollSecret `json:"secrets,omitempty"`
Role string `json:"role"`
}
if err := json.Unmarshal(b, &x); err != nil {
return err
}
*u = UserTeam{
Team: Team{
ID: x.ID,
CreatedAt: x.CreatedAt,
Name: x.Name,
Description: x.Description,
Config: x.TeamConfig,
UserCount: x.UserCount,
Users: x.Users,
HostCount: x.HostCount,
Hosts: x.Hosts,
Secrets: x.Secrets,
},
Role: x.Role,
}
return nil
}
// UserListOptions is additional options that can be set for listing users.
type UserListOptions struct {
ListOptions
// TeamID, if set, indicates to only return members of the identified team.
TeamID uint
}
// UserPayload is used to modify an existing user
type UserPayload struct {
Name *string `json:"name,omitempty"`
Email *string `json:"email,omitempty"`
Password *string `json:"password,omitempty"`
GravatarURL *string `json:"gravatar_url,omitempty"`
Position *string `json:"position,omitempty"`
InviteToken *string `json:"invite_token,omitempty"`
SSOInvite *bool `json:"sso_invite,omitempty"`
MFAEnabled *bool `json:"mfa_enabled,omitempty"`
SSOEnabled *bool `json:"sso_enabled,omitempty"`
GlobalRole *string `json:"global_role,omitempty"`
AdminForcedPasswordReset *bool `json:"admin_forced_password_reset,omitempty"`
APIOnly *bool `json:"api_only,omitempty"`
Teams *[]UserTeam `json:"teams,omitempty" renameto:"fleets"`
NewPassword *string `json:"new_password,omitempty"`
Settings *UserSettings `json:"settings,omitempty"`
InviteID *uint `json:"-"`
// If this is an API-only user, then this can be used to specify which
// API endpoints the user has access to
APIEndpoints *[]APIEndpointRef `json:"api_endpoints,omitempty"`
}
func (p *UserPayload) VerifyInviteCreate() error {
invalid := &InvalidArgumentError{}
p.verifyCreateShared(invalid)
if p.InviteToken == nil {
invalid.Append("invite_token", "Invite token missing required argument")
} else if *p.InviteToken == "" {
invalid.Append("invite_token", "Invite token cannot be empty")
}
if invalid.HasErrors() {
return invalid
}
return nil
}
func (p *UserPayload) VerifyAdminCreate() error {
invalid := &InvalidArgumentError{}
p.verifyCreateShared(invalid)
if p.InviteToken != nil {
invalid.Append("invite_token", "Invite token should not be specified with admin user creation")
}
if invalid.HasErrors() {
return invalid
}
return nil
}
func (p *UserPayload) verifyCreateShared(invalid *InvalidArgumentError) {
if p.Name == nil {
invalid.Append("name", "Full name missing required argument")
} else if *p.Name == "" {
invalid.Append("name", "Full name cannot be empty")
}
// we don't need a password for single sign on
if (p.SSOInvite == nil || !*p.SSOInvite) && (p.SSOEnabled == nil || !*p.SSOEnabled) {
if p.Password == nil {
invalid.Append("password", "Password missing required argument")
} else if *p.Password == "" {
invalid.Append("password", "Password cannot be empty")
} else if err := ValidatePasswordRequirements(*p.Password); err != nil {
invalid.Append("password", err.Error())
}
}
if p.SSOEnabled != nil && *p.SSOEnabled {
if p.Password != nil && len(*p.Password) > 0 {
invalid.Append("password", "not allowed for SSO users")
}
if p.MFAEnabled != nil && *p.MFAEnabled {
invalid.Append("mfa_enabled", "not applicable for SSO users")
}
}
if p.Email == nil {
invalid.Append("email", "Email missing required argument")
} else if *p.Email == "" {
invalid.Append("email", "Email cannot be empty")
} else if err := ValidateEmail(*p.Email); err != nil {
invalid.Append("email", err.Error())
}
}
func (p *UserPayload) VerifyModify(ownUser bool) error {
invalid := &InvalidArgumentError{}
if p.Name != nil && *p.Name == "" {
invalid.Append("name", "Full name cannot be empty")
}
if p.Email != nil {
if *p.Email == "" {
invalid.Append("email", "Email cannot be empty")
}
// if the user is not an admin, or if an admin is changing their own email
// address a password is required.
if ownUser && p.Password == nil {
invalid.Append("password", "Password cannot be empty if email is changed")
}
}
if p.SSOEnabled != nil && *p.SSOEnabled && p.NewPassword != nil && len(*p.NewPassword) > 0 {
invalid.Append("new_password", "not allowed for SSO users")
}
if p.NewPassword != nil {
// if the user is not an admin, or if an admin is changing their own password
// a password is required.
if ownUser && p.Password == nil {
invalid.Append("password", "Old password cannot be empty")
}
}
if invalid.HasErrors() {
return invalid
}
return nil
}
// User creates a user from payload.
func (p UserPayload) User(keySize, cost int) (*User, error) {
user := &User{
Name: *p.Name,
Email: *p.Email,
Teams: []UserTeam{},
}
if (p.SSOInvite != nil && *p.SSOInvite) || (p.SSOEnabled != nil && *p.SSOEnabled) {
user.SSOEnabled = true
// SSO user requires a stand-in password to satisfy `NOT NULL` constraint
err := user.SetFakePassword(keySize, cost)
if err != nil {
return nil, err
}
} else {
err := user.SetPassword(*p.Password, keySize, cost)
if err != nil {
return nil, err
}
if p.MFAEnabled != nil {
user.MFAEnabled = *p.MFAEnabled
}
}
// add optional fields
if p.GravatarURL != nil {
user.GravatarURL = *p.GravatarURL
}
if p.Position != nil {
user.Position = *p.Position
}
if p.SSOEnabled != nil {
user.SSOEnabled = *p.SSOEnabled
}
if p.AdminForcedPasswordReset != nil {
user.AdminForcedPasswordReset = *p.AdminForcedPasswordReset
}
if p.APIOnly != nil {
user.APIOnly = *p.APIOnly
if p.APIEndpoints != nil {
user.APIEndpoints = *p.APIEndpoints
}
}
if p.Teams != nil {
user.Teams = *p.Teams
}
if p.GlobalRole != nil {
user.GlobalRole = p.GlobalRole
}
if p.InviteID != nil {
user.InviteID = p.InviteID
}
return user, nil
}
// ValidatePassword accepts a potential password for a given user and attempts
// to validate it against the hash stored in the database after joining the
// supplied password with the stored password salt
func (u *User) ValidatePassword(password string) error {
saltAndPass := []byte(fmt.Sprintf("%s%s", password, u.Salt))
return bcrypt.CompareHashAndPassword(u.Password, saltAndPass)
}
func (u *User) SetPassword(plaintext string, keySize, cost int) error {
if u.SSOEnabled {
return errors.New("set password for single sign on user not allowed")
}
if err := ValidatePasswordRequirements(plaintext); err != nil {
return err
}
hashed, salt, err := saltAndHashPassword(keySize, plaintext, cost)
if err != nil {
return err
}
u.Password = hashed
u.Salt = salt
return nil
}
// ValidatePasswordRequirements checks the provided password against the following requirements:
// at least 12 character length
// at least 1 symbol
// at least 1 number
func ValidatePasswordRequirements(password string) error {
var (
number bool
symbol bool
)
for _, s := range password {
switch {
case unicode.IsNumber(s):
number = true
case unicode.IsPunct(s) || unicode.IsSymbol(s):
symbol = true
}
}
if len(password) >= 12 &&
number &&
symbol {
return nil
}
return errors.New("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. &*#).")
}
// ValidateEmail checks that the provided email address is valid, this function
// uses the stdlib func `mail.ParseAddress` underneath, which parses email
// adddresses using RFC5322, so it properly parses strings like "User
// <example.com>" thus we check if:
//
// 1. We're able to parse the address
// 2. The parsed address is equal to the provided address
func ValidateEmail(email string) error {
addr, err := mail.ParseAddress(email)
if err != nil {
return err
}
if addr.Address != email {
return errors.New("Email is invalid")
}
return nil
}
// SetFakePassword sets a stand-in password consisting of random text generated by filling in keySize bytes with
// random data and then base64 encoding those bytes.
//
// Usage should be limited to cases such as SSO users where a stand-in password is needed to satisfy `NOT NULL` constraints.
// There is no guarantee that the generated password will otherwise satisfy complexity, length or
// other requirements of standard password validation.
func (u *User) SetFakePassword(keySize, cost int) error {
plaintext, err := server.GenerateRandomText(14)
if err != nil {
return err
}
hashed, salt, err := saltAndHashPassword(keySize, plaintext, cost)
if err != nil {
return err
}
u.Password = hashed
u.Salt = salt
return nil
}
func (u *User) TeamIDsWithAnyRole() (teamIDs []uint) {
for _, team := range u.Teams {
teamIDs = append(teamIDs, team.ID)
}
return teamIDs
}
func (u *User) HasAnyGlobalRole() bool {
return u.GlobalRole != nil
}
func (u *User) HasAnyTeamRole() bool {
return len(u.Teams) > 0
}
func (u *User) HasAnyRoleInTeam(id uint) bool {
for _, team := range u.Teams {
if team.ID == id {
return true
}
}
return false
}
func saltAndHashPassword(keySize int, plaintext string, cost int) (hashed []byte, salt string, err error) {
salt, err = server.GenerateRandomText(keySize)
if err != nil {
return nil, "", err
}
salt = salt[:keySize]
withSalt := []byte(fmt.Sprintf("%s%s", plaintext, salt))
hashed, err = bcrypt.GenerateFromPassword(withSalt, cost)
if err != nil {
if errors.Is(err, bcrypt.ErrPasswordTooLong) {
return nil, "", NewInvalidArgumentError("Could not create user. Password is over the 48 characters limit. If the password is under 48 characters, please check the auth_salt_key_size in your Fleet server config.", "password too long")
}
return nil, "", err
}
return hashed, salt, nil
}