mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 17:05:18 +00:00
ea47ebfb32
Loom explaining changes: https://www.loom.com/share/f05f241a77304c19bc6ba1d0702c7bd8?sid=ea86b282-0bda-4ba4-a6cf-4520f0db610d #14571 Enabled support and validation of 'after' parameter for the following endpoints: - GET /api/v1/fleet/carves Setting 'after' parameter no longer returns SQL syntax error for the following endpoints: - GET /api/v1/fleet/carves - GET /api/v1/fleet/invites - GET /api/v1/fleet/labels - GET /api/v1/fleet/packs - GET /api/v1/fleet/global/policies - GET /api/v1/fleet/teams/{id}/policies - GET /api/v1/fleet/queries - GET /api/v1/fleet/packs/{id}/scheduled - GET /api/v1/fleet/teams - GET /api/v1/fleet/users API doc changes PR: https://github.com/fleetdm/fleet/pull/15061 - [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] Added/updated tests - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Rachael Shaw <r@rachael.wtf>
304 lines
7.8 KiB
Go
304 lines
7.8 KiB
Go
package mysql
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"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"}
|
|
|
|
// 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,
|
|
api_only,
|
|
global_role
|
|
) VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
`
|
|
result, err := tx.ExecContext(ctx, sqlStatement,
|
|
user.Password,
|
|
user.Salt,
|
|
user.Name,
|
|
user.Email,
|
|
user.AdminForcedPasswordReset,
|
|
user.GravatarURL,
|
|
user.Position,
|
|
user.SSOEnabled,
|
|
user.APIOnly,
|
|
user.GlobalRole)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "create new user")
|
|
}
|
|
|
|
id, _ := result.LastInsertId()
|
|
user.ID = uint(id)
|
|
|
|
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 * FROM users "+
|
|
"WHERE %s = ? LIMIT 1",
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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) 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 = ?,
|
|
api_only = ?,
|
|
global_role = ?
|
|
WHERE id = ?
|
|
`
|
|
result, err := tx.ExecContext(ctx, sqlStatement,
|
|
user.Password,
|
|
user.Salt,
|
|
user.Name,
|
|
user.Email,
|
|
user.AdminForcedPasswordReset,
|
|
user.GravatarURL,
|
|
user.Position,
|
|
user.SSOEnabled,
|
|
user.APIOnly,
|
|
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 {
|
|
return ds.deleteEntity(ctx, usersTable, id)
|
|
}
|
|
|
|
func amountUsersDB(ctx context.Context, db sqlx.QueryerContext) (int, error) {
|
|
var amount int
|
|
err := sqlx.GetContext(ctx, db, &amount, `SELECT count(*) FROM users`)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return amount, 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
|
|
}
|