Files
mailgoserver/internal/db/crud_admin.go
T

365 lines
13 KiB
Go

package db
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"time"
)
const adminUserColumns = `id, username, password_hash, must_change_password, must_change_username, totp_secret, totp_enabled, is_global_admin, created_by, created_at`
func scanAdminUser(row *sql.Row) (*AdminUser, error) {
var u AdminUser
var createdAt string
var createdBy sql.NullInt64
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.MustChangePassword, &u.MustChangeUsername, &u.TOTPSecret, &u.TOTPEnabled, &u.IsGlobalAdmin, &createdBy, &createdAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
u.CreatedAt, _ = parseTime(createdAt)
if createdBy.Valid {
u.CreatedBy = &createdBy.Int64
}
return &u, nil
}
func (d *DB) CountAdminUsers() (int, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_admin_users`).Scan(&n)
return n, err
}
// DefaultAdminUsername/Password are the seeded first-run credentials — the admin is
// forced to change both before they can use the rest of the dashboard (see
// AdminUser.MustChangePassword and the login flow).
const (
DefaultAdminUsername = "admin"
DefaultAdminPassword = "Password123!"
)
// SeedDefaultAdminIfEmpty creates the default admin account on a brand-new install
// (no admin users yet at all) with must_change_password AND must_change_username set,
// so the well-known default credentials (username "admin") can never be left in place
// silently. This is the one and only place must_change_username is ever set — every
// other admin (delegated, or a global admin created via the delegation flow) picks
// their own username up front and only needs to set their own password.
func (d *DB) SeedDefaultAdminIfEmpty() error {
n, err := d.CountAdminUsers()
if err != nil {
return err
}
if n > 0 {
return nil
}
hash, err := HashPassword(DefaultAdminPassword)
if err != nil {
return err
}
_, err = d.Exec(`INSERT INTO esrv_admin_users (username, password_hash, must_change_password, must_change_username, is_global_admin) VALUES (?, ?, 1, 1, 1)`,
DefaultAdminUsername, hash)
return err
}
// CreateAdminUser inserts a new global-admin account (full access, no domain
// restriction) — used by the delegation flow when a global admin grants another user
// global access. mustChangePassword should be true so the admin who set the initial
// password isn't the only one who knows it; must_change_username is always false
// here, since the account was created with the username the new admin will actually
// use (see SeedDefaultAdminIfEmpty for the one exception).
func (d *DB) CreateAdminUser(username, passwordHash string, mustChangePassword bool) (int64, error) {
res, err := d.Exec(`INSERT INTO esrv_admin_users (username, password_hash, must_change_password, must_change_username, is_global_admin) VALUES (?, ?, ?, 0, 1)`,
username, passwordHash, mustChangePassword)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// CreateScopedAdminUser inserts a new domain-scoped admin (delegated access), owned by
// createdBy, and grants it access to exactly domainIDs — mirrors the delegation flow:
// a scoped admin can create other scoped admins limited to domains within their own.
func (d *DB) CreateScopedAdminUser(username, passwordHash string, createdBy int64, domainIDs []int64) (int64, error) {
tx, err := d.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
res, err := tx.Exec(`INSERT INTO esrv_admin_users (username, password_hash, must_change_password, must_change_username, is_global_admin, created_by) VALUES (?, ?, 1, 0, 0, ?)`,
username, passwordHash, createdBy)
if err != nil {
return 0, err
}
id, err := res.LastInsertId()
if err != nil {
return 0, err
}
for _, domainID := range domainIDs {
if _, err := tx.Exec(`INSERT INTO esrv_admin_domain_access (admin_user_id, domain_id) VALUES (?, ?)`, id, domainID); err != nil {
return 0, err
}
}
return id, tx.Commit()
}
// ListAllAdminUsers returns every admin account — for a global admin's user-management
// view.
func (d *DB) ListAllAdminUsers() ([]AdminUser, error) {
rows, err := d.Query(`SELECT ` + adminUserColumns + ` FROM esrv_admin_users ORDER BY username`)
if err != nil {
return nil, err
}
return scanAdminUsers(rows)
}
// ListScopedAdminUsers returns every non-global admin. Combined with AccessibleDomainIDs
// per user, this lets the caller compute "which of these can I (a scoped admin)
// manage" — the subset check happens in Go since the admin counts here are always
// small (a handful of delegated accounts, not enterprise scale).
func (d *DB) ListScopedAdminUsers() ([]AdminUser, error) {
rows, err := d.Query(`SELECT ` + adminUserColumns + ` FROM esrv_admin_users WHERE is_global_admin = 0 ORDER BY username`)
if err != nil {
return nil, err
}
return scanAdminUsers(rows)
}
func scanAdminUsers(rows *sql.Rows) ([]AdminUser, error) {
defer rows.Close()
var out []AdminUser
for rows.Next() {
var u AdminUser
var createdAt string
var createdBy sql.NullInt64
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.MustChangePassword, &u.MustChangeUsername, &u.TOTPSecret, &u.TOTPEnabled, &u.IsGlobalAdmin, &createdBy, &createdAt); err != nil {
return nil, err
}
u.CreatedAt, _ = parseTime(createdAt)
if createdBy.Valid {
u.CreatedBy = &createdBy.Int64
}
out = append(out, u)
}
return out, rows.Err()
}
// AccessibleDomainIDs returns the domains a scoped admin can see/manage. Meaningless
// for a global admin (they can access everything regardless of this table).
func (d *DB) AccessibleDomainIDs(userID int64) ([]int64, error) {
rows, err := d.Query(`SELECT domain_id FROM esrv_admin_domain_access WHERE admin_user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// GrantDomainAccess mirrors auto-assigning a newly-created domain to the scoped admin
// who created it.
func (d *DB) GrantDomainAccess(userID, domainID int64) error {
_, err := d.Exec(`INSERT OR IGNORE INTO esrv_admin_domain_access (admin_user_id, domain_id) VALUES (?, ?)`, userID, domainID)
return err
}
// SetAdminDomainAccess replaces a scoped admin's entire domain assignment set.
func (d *DB) SetAdminDomainAccess(userID int64, domainIDs []int64) error {
tx, err := d.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM esrv_admin_domain_access WHERE admin_user_id = ?`, userID); err != nil {
return err
}
for _, id := range domainIDs {
if _, err := tx.Exec(`INSERT INTO esrv_admin_domain_access (admin_user_id, domain_id) VALUES (?, ?)`, userID, id); err != nil {
return err
}
}
return tx.Commit()
}
// DeleteAdminUser removes an admin account and everything tied to it.
func (d *DB) DeleteAdminUser(id int64) error {
tx, err := d.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, stmt := range []string{
`DELETE FROM esrv_admin_domain_access WHERE admin_user_id = ?`,
`DELETE FROM esrv_admin_sessions WHERE user_id = ?`,
`DELETE FROM esrv_webauthn_credentials WHERE user_id = ?`,
`DELETE FROM esrv_admin_users WHERE id = ?`,
} {
if _, err := tx.Exec(stmt, id); err != nil {
return err
}
}
return tx.Commit()
}
func (d *DB) GetAdminUserByUsername(username string) (*AdminUser, error) {
row := d.QueryRow(`SELECT `+adminUserColumns+` FROM esrv_admin_users WHERE lower(username) = lower(?)`, username)
return scanAdminUser(row)
}
func (d *DB) GetAdminUserByID(id int64) (*AdminUser, error) {
row := d.QueryRow(`SELECT `+adminUserColumns+` FROM esrv_admin_users WHERE id = ?`, id)
return scanAdminUser(row)
}
// UpdateAdminCredentials mirrors the forced first-login change for the seeded default
// admin: new username, password hash, and clears must_change_password/
// must_change_username in one step.
func (d *DB) UpdateAdminCredentials(id int64, username, passwordHash string) error {
_, err := d.Exec(`UPDATE esrv_admin_users SET username = ?, password_hash = ?, must_change_password = 0, must_change_username = 0 WHERE id = ?`, username, passwordHash, id)
return err
}
// UpdateAdminPasswordClearMustChange mirrors the forced first-login change for a
// delegated admin: password hash only (the username was already chosen when the
// account was created), clearing must_change_password.
func (d *DB) UpdateAdminPasswordClearMustChange(id int64, passwordHash string) error {
_, err := d.Exec(`UPDATE esrv_admin_users SET password_hash = ?, must_change_password = 0, must_change_username = 0 WHERE id = ?`, passwordHash, id)
return err
}
func (d *DB) UpdateAdminPassword(id int64, passwordHash string) error {
_, err := d.Exec(`UPDATE esrv_admin_users SET password_hash = ? WHERE id = ?`, passwordHash, id)
return err
}
func (d *DB) SetAdminTOTPSecret(id int64, secret string, enabled bool) error {
_, err := d.Exec(`UPDATE esrv_admin_users SET totp_secret = ?, totp_enabled = ? WHERE id = ?`, secret, enabled, id)
return err
}
func (d *DB) DisableAdminTOTP(id int64) error {
_, err := d.Exec(`UPDATE esrv_admin_users SET totp_secret = '', totp_enabled = 0 WHERE id = ?`, id)
return err
}
// ResetAdminMFA clears every second factor an admin has enrolled — TOTP and every
// registered passkey — e.g. after a lost device, so they can re-enroll from scratch.
// Distinct from DisableAdminTOTP (TOTP only, self-service from /account): this is the
// admin-management action a manager takes on someone else's account (see
// adminWithManageAccess's delegation rule for who's allowed to).
func (d *DB) ResetAdminMFA(id int64) error {
tx, err := d.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE esrv_admin_users SET totp_secret = '', totp_enabled = 0 WHERE id = ?`, id); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM esrv_webauthn_credentials WHERE user_id = ?`, id); err != nil {
return err
}
return tx.Commit()
}
// --- Sessions ---
func newSessionToken() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
// CreateSession mirrors starting a new login session; mfaVerified should be true only
// when the account has no second factor enabled (nothing left to verify) or the second
// factor was just satisfied.
func (d *DB) CreateSession(userID int64, mfaVerified bool, ttl time.Duration) (string, error) {
token := newSessionToken()
_, err := d.Exec(`INSERT INTO esrv_admin_sessions (token, user_id, mfa_verified, expires_at) VALUES (?, ?, ?, ?)`,
token, userID, mfaVerified, time.Now().Add(ttl))
if err != nil {
return "", err
}
return token, nil
}
func (d *DB) GetSession(token string) (*AdminSession, error) {
row := d.QueryRow(`SELECT token, user_id, mfa_verified, created_at, expires_at FROM esrv_admin_sessions WHERE token = ?`, token)
var s AdminSession
var createdAt, expiresAt string
if err := row.Scan(&s.Token, &s.UserID, &s.MFAVerified, &createdAt, &expiresAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
s.CreatedAt, _ = parseTime(createdAt)
s.ExpiresAt, _ = parseTime(expiresAt)
return &s, nil
}
func (d *DB) MarkSessionMFAVerified(token string) error {
_, err := d.Exec(`UPDATE esrv_admin_sessions SET mfa_verified = 1 WHERE token = ?`, token)
return err
}
func (d *DB) DeleteSession(token string) error {
_, err := d.Exec(`DELETE FROM esrv_admin_sessions WHERE token = ?`, token)
return err
}
// DeleteExpiredSessions is a lightweight best-effort sweep, called opportunistically
// rather than on a schedule — this admin UI has at most a handful of sessions ever.
func (d *DB) DeleteExpiredSessions() error {
_, err := d.Exec(`DELETE FROM esrv_admin_sessions WHERE expires_at < ?`, time.Now())
return err
}
// --- WebAuthn credentials ---
func (d *DB) ListWebAuthnCredentials(userID int64) ([]WebAuthnCredential, error) {
rows, err := d.Query(`SELECT id, user_id, name, credential_id, credential_data, created_at FROM esrv_webauthn_credentials WHERE user_id = ? ORDER BY created_at`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []WebAuthnCredential
for rows.Next() {
var c WebAuthnCredential
var createdAt string
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.CredentialID, &c.CredentialData, &createdAt); err != nil {
return nil, err
}
c.CreatedAt, _ = parseTime(createdAt)
out = append(out, c)
}
return out, rows.Err()
}
func (d *DB) CreateWebAuthnCredential(userID int64, name, credentialID, credentialData string) error {
_, err := d.Exec(`INSERT INTO esrv_webauthn_credentials (user_id, name, credential_id, credential_data) VALUES (?, ?, ?, ?)`,
userID, name, credentialID, credentialData)
return err
}
func (d *DB) DeleteWebAuthnCredential(id, userID int64) error {
_, err := d.Exec(`DELETE FROM esrv_webauthn_credentials WHERE id = ? AND user_id = ?`, id, userID)
return err
}
func (d *DB) CountWebAuthnCredentials(userID int64) (int, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_webauthn_credentials WHERE user_id = ?`, userID).Scan(&n)
return n, err
}