fleet/server/datastore/mysql/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

441 lines
12 KiB
Go

package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/jmoiron/sqlx"
)
var userSearchColumns = []string{"name", "email"}
// userSelectColumns contains everything except `settings`. Since we only want to include
// user settings on an opt-in basis from the API perspective (see `include_ui_settings`
// query param on `GET` `/me` and `GET` `/users/:id`), excluding it here ensures it's only
// included in API responses when explicitly coded to be, via calling the dedicated
// UserSettings method. Otherwise, `settings` would be included in `user` objects in
// various places, which we do not want.
const userSelectColumns = `id, created_at, updated_at, password, salt, name, email,
admin_forced_password_reset, gravatar_url, position, sso_enabled, global_role,
api_only, mfa_enabled, invite_id`
// userSummaryColumns are the columns selected for UserSummary.
const userSummaryColumns = `id, name, email, gravatar_url, api_only`
// NewUser creates a new user
func (ds *Datastore) NewUser(ctx context.Context, user *fleet.User) (*fleet.User, error) {
if err := fleet.ValidateRole(user.GlobalRole, user.Teams); err != nil {
return nil, ctxerr.Wrap(ctx, err, "validate role")
}
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
sqlStatement := `
INSERT INTO users (
password,
salt,
name,
email,
admin_forced_password_reset,
gravatar_url,
position,
sso_enabled,
mfa_enabled,
api_only,
global_role,
invite_id
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
`
result, err := tx.ExecContext(ctx, sqlStatement,
user.Password,
user.Salt,
user.Name,
user.Email,
user.AdminForcedPasswordReset,
user.GravatarURL,
user.Position,
user.SSOEnabled,
user.MFAEnabled,
user.APIOnly,
user.GlobalRole,
user.InviteID,
)
// set timestamp as close as possible to insert query to be as accurate as possible without needing to SELECT
user.CreatedAt = time.Now().UTC().Truncate(time.Second) // truncating because DB is at second resolution
user.UpdatedAt = user.CreatedAt
if err != nil {
return ctxerr.Wrap(ctx, err, "create new user")
}
id, _ := result.LastInsertId()
user.ID = uint(id) //nolint:gosec // dismiss G115
if err := saveTeamsForUserDB(ctx, tx, user); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return user, nil
}
func (ds *Datastore) findUser(ctx context.Context, searchCol string, searchVal interface{}) (*fleet.User, error) {
sqlStatement := fmt.Sprintf(
"SELECT %s FROM users WHERE %s = ? LIMIT 1",
userSelectColumns, searchCol,
)
user := &fleet.User{}
err := sqlx.GetContext(ctx, ds.reader(ctx), user, sqlStatement, searchVal)
if err != nil && err == sql.ErrNoRows {
return nil, ctxerr.Wrap(ctx, notFound("User").
WithMessage(fmt.Sprintf("with %s=%v", searchCol, searchVal)))
} else if err != nil {
return nil, ctxerr.Wrap(ctx, err, "find user")
}
if err := ds.loadTeamsForUsers(ctx, []*fleet.User{user}); err != nil {
return nil, ctxerr.Wrap(ctx, err, "load teams")
}
// When SSO is enabled, we can ignore forced password resets
// However, we want to leave the db untouched, to cover cases where SSO is toggled
if user.SSOEnabled {
user.AdminForcedPasswordReset = false
}
return user, nil
}
// HasUsers returns whether Fleet has any users registered
func (ds *Datastore) HasUsers(ctx context.Context) (bool, error) {
var id uint
if err := sqlx.GetContext(ctx, ds.reader(ctx), &id, `SELECT id FROM users LIMIT 1`); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, ctxerr.Wrap(ctx, err, "has users check")
}
return id > 0, nil
}
// ListUsers lists all users with team ID, limit, sort and offset passed in with
// UserListOptions.
func (ds *Datastore) ListUsers(ctx context.Context, opt fleet.UserListOptions) ([]*fleet.User, error) {
sqlStatement := `
SELECT * FROM users
WHERE TRUE
`
var params []interface{}
if opt.TeamID != 0 {
sqlStatement += " AND id IN (SELECT user_id FROM user_teams WHERE team_id = ?)"
params = append(params, opt.TeamID)
}
sqlStatement, params = searchLike(sqlStatement, params, opt.MatchQuery, userSearchColumns...)
sqlStatement, params = appendListOptionsWithCursorToSQL(sqlStatement, params, &opt.ListOptions)
users := []*fleet.User{}
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &users, sqlStatement, params...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "list users")
}
if err := ds.loadTeamsForUsers(ctx, users); err != nil {
return nil, ctxerr.Wrap(ctx, err, "load teams")
}
return users, nil
}
// UsersByIDs returns user summaries matching the provided IDs.
func (ds *Datastore) UsersByIDs(ctx context.Context, ids []uint) ([]*fleet.UserSummary, error) {
if len(ids) == 0 {
return nil, nil
}
query, args, err := sqlx.In(
fmt.Sprintf("SELECT %s FROM users WHERE id IN (?)", userSummaryColumns), ids)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build users by IDs query")
}
var users []*fleet.UserSummary
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &users, query, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "select users by IDs")
}
return users, nil
}
func (ds *Datastore) UserByEmail(ctx context.Context, email string) (*fleet.User, error) {
return ds.findUser(ctx, "email", email)
}
func (ds *Datastore) UserByID(ctx context.Context, id uint) (*fleet.User, error) {
return ds.findUser(ctx, "id", id)
}
func (ds *Datastore) UserOrDeletedUserByID(ctx context.Context, id uint) (*fleet.User, error) {
user, err := ds.findUser(ctx, "id", id)
switch {
case fleet.IsNotFound(err):
return ds.deletedUserByID(ctx, id)
case err != nil:
return nil, ctxerr.Wrap(ctx, err, "find user")
}
return user, nil
}
func (ds *Datastore) deletedUserByID(ctx context.Context, id uint) (*fleet.User, error) {
stmt := `SELECT id, name, email, 1 as deleted FROM users_deleted WHERE id = ?`
var user fleet.User
err := sqlx.GetContext(ctx, ds.reader(ctx), &user, stmt, id)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ctxerr.Wrap(ctx, notFound("deleted user").WithID(id))
case err != nil:
return nil, ctxerr.Wrap(ctx, err, "selecting deleted user")
}
return &user, nil
}
func (ds *Datastore) SaveUser(ctx context.Context, user *fleet.User) error {
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
return saveUserDB(ctx, tx, user)
})
}
func (ds *Datastore) SaveUsers(ctx context.Context, users []*fleet.User) error {
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
for _, user := range users {
err := saveUserDB(ctx, tx, user)
if err != nil {
return err
}
}
return nil
})
}
func saveUserDB(ctx context.Context, tx sqlx.ExtContext, user *fleet.User) error {
if err := fleet.ValidateRole(user.GlobalRole, user.Teams); err != nil {
return ctxerr.Wrap(ctx, err, "validate role")
}
sqlStatement := `
UPDATE users SET
password = ?,
salt = ?,
name = ?,
email = ?,
admin_forced_password_reset = ?,
gravatar_url = ?,
position = ?,
sso_enabled = ?,
mfa_enabled = ?,
api_only = ?,
settings = ?,
global_role = ?
WHERE id = ?
`
settingsBytes, err := json.Marshal(user.Settings)
if err != nil {
return ctxerr.Wrap(ctx, err, "marshaling user settings")
}
result, err := tx.ExecContext(ctx, sqlStatement,
user.Password,
user.Salt,
user.Name,
user.Email,
user.AdminForcedPasswordReset,
user.GravatarURL,
user.Position,
user.SSOEnabled,
user.MFAEnabled,
user.APIOnly,
settingsBytes,
user.GlobalRole,
user.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "save user")
}
rows, err := result.RowsAffected()
if err != nil {
return ctxerr.Wrap(ctx, err, "rows affected save user")
}
if rows == 0 {
return ctxerr.Wrap(ctx, notFound("User").WithID(user.ID))
}
// REVIEW: Check if teams have been set?
if err := saveTeamsForUserDB(ctx, tx, user); err != nil {
return err
}
return nil
}
// loadTeamsForUsers will load the teams/roles for the provided users.
func (ds *Datastore) loadTeamsForUsers(ctx context.Context, users []*fleet.User) error {
userIDs := make([]uint, 0, len(users)+1)
// Make sure the slice is never empty for IN by filling a nonexistent ID
userIDs = append(userIDs, 0)
idToUser := make(map[uint]*fleet.User, len(users))
for _, u := range users {
// Initialize empty slice so we get an array in JSON responses instead
// of null if it is empty
u.Teams = []fleet.UserTeam{}
// Track IDs for queries and matching
userIDs = append(userIDs, u.ID)
idToUser[u.ID] = u
}
sql := `
SELECT ut.team_id AS id, ut.user_id, ut.role, t.name
FROM user_teams ut INNER JOIN teams t ON ut.team_id = t.id
WHERE ut.user_id IN (?)
ORDER BY user_id, team_id
`
sql, args, err := sqlx.In(sql, userIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "sqlx.In loadTeamsForUsers")
}
var rows []struct {
fleet.UserTeam
UserID uint `db:"user_id"`
}
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, sql, args...); err != nil {
return ctxerr.Wrap(ctx, err, "get loadTeamsForUsers")
}
// Map each row to the appropriate user
for _, r := range rows {
user := idToUser[r.UserID]
user.Teams = append(user.Teams, r.UserTeam)
}
return nil
}
func saveTeamsForUserDB(ctx context.Context, tx sqlx.ExtContext, user *fleet.User) error {
// Do a full teams update by deleting existing teams and then inserting all
// the current teams in a single transaction.
// Delete before insert
sql := `DELETE FROM user_teams WHERE user_id = ?`
if _, err := tx.ExecContext(ctx, sql, user.ID); err != nil {
return ctxerr.Wrap(ctx, err, "delete existing teams")
}
if len(user.Teams) == 0 {
return nil
}
// Bulk insert
const valueStr = "(?,?,?),"
var args []interface{}
for _, userTeam := range user.Teams {
args = append(args, user.ID, userTeam.Team.ID, userTeam.Role)
}
sql = "INSERT INTO user_teams (user_id, team_id, role) VALUES " +
strings.Repeat(valueStr, len(user.Teams))
sql = strings.TrimSuffix(sql, ",")
if _, err := tx.ExecContext(ctx, sql, args...); err != nil {
return ctxerr.Wrap(ctx, err, "insert teams")
}
return nil
}
// DeleteUser deletes the associated user
func (ds *Datastore) DeleteUser(ctx context.Context, id uint) error {
// Transfer user data to deleted_users table for audit/activity purposes
stmt := `
INSERT INTO users_deleted (id, name, email)
SELECT u.id, u.name, u.email
FROM users AS u
WHERE u.id = ?
ON DUPLICATE KEY UPDATE
name = u.name,
email = u.email`
_, err := ds.writer(ctx).ExecContext(ctx, stmt, id)
if err != nil {
return ctxerr.Wrap(ctx, err, "populate users_deleted entry")
}
return ds.deleteEntity(ctx, usersTable, id)
}
func (ds *Datastore) CountGlobalAdmins(ctx context.Context) (int, error) {
var count int
err := sqlx.GetContext(ctx, ds.writer(ctx), &count, `SELECT COUNT(*) FROM users WHERE global_role = 'admin'`)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "count global admins")
}
return count, nil
}
func tableRowsCount(ctx context.Context, db sqlx.QueryerContext, tableName string) (int, error) {
var count int
err := sqlx.GetContext(ctx, db, &count, fmt.Sprintf(`SELECT count(*) FROM %s`, tableName))
if err != nil {
return 0, err
}
return count, nil
}
func amountActiveUsersSinceDB(ctx context.Context, db sqlx.QueryerContext, since time.Time) (int, error) {
var amount int
err := sqlx.GetContext(ctx, db, &amount, `
SELECT count(*)
FROM users u
WHERE EXISTS (
SELECT 1
FROM sessions ssn
WHERE ssn.user_id = u.id AND
ssn.accessed_at >= ?
)`, since)
if err != nil {
return 0, err
}
return amount, nil
}
func (ds *Datastore) UserSettings(ctx context.Context, userID uint) (*fleet.UserSettings, error) {
settings := &fleet.UserSettings{}
var bytes []byte
stmt := `
SELECT settings FROM users WHERE id = ?
`
err := sqlx.GetContext(ctx, ds.reader(ctx), &bytes, stmt, userID)
if err != nil {
if err == sql.ErrNoRows {
return nil, ctxerr.Wrap(ctx, notFound("UserSettings").WithID(userID))
}
return nil, ctxerr.Wrap(ctx, err, "selecting user settings")
}
if len(bytes) == 0 {
return settings, nil
}
err = json.Unmarshal(bytes, settings)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "unmarshaling user settings")
}
return settings, nil
}