fleet/server/datastore/mysql/users.go
Juan Fernandez f791f4b309
Allow the creation of API-only users (#43440)
**Related issues:** 
- Resolves #42882 
- Resolves #42880 
- Resolves #42884 

# Changes

- Added POST /users/api_only endpoint for creating API-only users.
- Added PATCH /users/api_only/{id} for updating existing API-only users.
- Updated `fleetctl user create --api-only` removing email/password
field requirements.
2026-04-16 11:11:39 -04:00

594 lines
17 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"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"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
}
if user.APIOnly && user.APIEndpoints != nil {
if err := replaceUserAPIEndpoints(ctx, tx, user.ID, user.APIEndpoints); 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")
}
if err := ds.loadAPIEndpointsForUsers(ctx, []*fleet.User{user}); err != nil {
return nil, ctxerr.Wrap(ctx, err, "load api endpoints")
}
// 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
}
// userAllowedOrderKeys defines the allowed order keys for ListUsers.
// SECURITY: This prevents information disclosure via arbitrary column sorting.
// Sensitive columns like 'password' and 'salt' are intentionally excluded.
var userAllowedOrderKeys = common_mysql.OrderKeyAllowlist{
"name": "name",
"email": "email",
"created_at": "created_at",
"updated_at": "updated_at",
"id": "id",
}
// 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...)
var err error
sqlStatement, params, err = appendListOptionsWithCursorToSQLSecure(sqlStatement, params, &opt.ListOptions, userAllowedOrderKeys)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "apply list options")
}
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")
}
if err := ds.loadAPIEndpointsForUsers(ctx, users); err != nil {
return nil, ctxerr.Wrap(ctx, err, "load api endpoints")
}
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 {
if err := saveUserDB(ctx, tx, user); 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
}
if user.APIOnly {
if err := replaceUserAPIEndpoints(ctx, tx, user.ID, user.APIEndpoints); err != nil {
return err
}
}
return nil
}
// loadAPIEndpointsForUsers loads api_endpoints for any API-only users in the slice.
func (ds *Datastore) loadAPIEndpointsForUsers(ctx context.Context, users []*fleet.User) error {
var apiOnlyIDs []uint
for _, u := range users {
if u.APIOnly {
apiOnlyIDs = append(apiOnlyIDs, u.ID)
}
}
if len(apiOnlyIDs) == 0 {
return nil
}
query, args, err := sqlx.In(
`SELECT user_id, method, path FROM user_api_endpoints WHERE user_id IN (?) ORDER BY method, path`,
apiOnlyIDs,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "build load api endpoints query")
}
var rows []struct {
UserID uint `db:"user_id"`
Method string `db:"method"`
Path string `db:"path"`
}
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, query, args...); err != nil {
return ctxerr.Wrap(ctx, err, "load api endpoints for users")
}
byUserID := make(map[uint][]fleet.APIEndpointRef, len(apiOnlyIDs))
for _, row := range rows {
byUserID[row.UserID] = append(byUserID[row.UserID], fleet.APIEndpointRef{
Method: row.Method,
Path: row.Path,
})
}
for _, u := range users {
if u.APIOnly {
u.APIEndpoints = byUserID[u.ID]
}
}
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)
}
// DeleteUserIfNotLastAdmin atomically checks that the user being deleted is not the
// last global admin before deleting. It uses SELECT ... FOR UPDATE to prevent concurrent
// requests from bypassing the check (TOCTOU race condition).
func (ds *Datastore) DeleteUserIfNotLastAdmin(ctx context.Context, id uint) error {
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
// Lock the admin rows to prevent concurrent modifications.
var count int
if err := sqlx.GetContext(ctx, tx, &count,
`SELECT COUNT(*) FROM users WHERE global_role = 'admin' FOR UPDATE`); err != nil {
return ctxerr.Wrap(ctx, err, "count global admins for delete")
}
if count <= 1 {
return fleet.ErrLastGlobalAdmin
}
// 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`
if _, err := tx.ExecContext(ctx, stmt, id); err != nil {
return ctxerr.Wrap(ctx, err, "populate users_deleted entry")
}
res, err := tx.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
if err != nil {
return ctxerr.Wrap(ctx, err, "delete user")
}
rows, _ := res.RowsAffected()
if rows != 1 {
return ctxerr.Wrap(ctx, notFound("User").WithID(id))
}
return nil
})
}
// SaveUserIfNotLastAdmin atomically checks that there's more than one admin
// before saving the user. Returns ErrLastGlobalAdmin if there's only one last global admin.
//
// It uses SELECT ... FOR UPDATE to prevent concurrent requests from bypassing
// the check (TOCTOU race condition).
func (ds *Datastore) SaveUserIfNotLastAdmin(ctx context.Context, user *fleet.User) error {
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
// Lock the admin rows to prevent concurrent modifications.
var count int
if err := sqlx.GetContext(ctx, tx, &count,
`SELECT COUNT(*) FROM users WHERE global_role = 'admin' FOR UPDATE`); err != nil {
return ctxerr.Wrap(ctx, err, "count global admins for save")
}
if count <= 1 {
return fleet.ErrLastGlobalAdmin
}
return saveUserDB(ctx, tx, user)
})
}
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
}
// replaceUserAPIEndpoints replaces all API endpoint permissions for the given user.
func replaceUserAPIEndpoints(ctx context.Context, tx sqlx.ExtContext, userID uint, endpoints []fleet.APIEndpointRef) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM user_api_endpoints WHERE user_id = ?`, userID); err != nil {
return ctxerr.Wrap(ctx, err, "delete user api endpoints")
}
if len(endpoints) == 0 {
return nil
}
placeholders := strings.Repeat("(?, ?, ?),", len(endpoints))
placeholders = placeholders[:len(placeholders)-1]
args := make([]any, 0, len(endpoints)*3)
for _, ep := range endpoints {
args = append(args, userID, ep.Path, ep.Method)
}
_, err := tx.ExecContext(ctx,
`INSERT INTO user_api_endpoints (user_id, path, method) VALUES `+placeholders,
args...,
)
return ctxerr.Wrap(ctx, err, "insert user api endpoints")
}