2026-08-12 12:56:22 +01:00
package db
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"time"
)
2026-08-13 08:07:19 +01:00
const adminUserColumns = `id, username, password_hash, must_change_password, must_change_username, totp_secret, totp_enabled, is_global_admin, created_by, created_at`
2026-08-12 12:56:22 +01:00
func scanAdminUser ( row * sql . Row ) ( * AdminUser , error ) {
var u AdminUser
var createdAt string
var createdBy sql . NullInt64
2026-08-13 08:07:19 +01:00
if err := row . Scan ( & u . ID , & u . Username , & u . PasswordHash , & u . MustChangePassword , & u . MustChangeUsername , & u . TOTPSecret , & u . TOTPEnabled , & u . IsGlobalAdmin , & createdBy , & createdAt ); err != nil {
2026-08-12 12:56:22 +01:00
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
2026-08-13 08:07:19 +01:00
// (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.
2026-08-12 12:56:22 +01:00
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
}
2026-08-13 08:07:19 +01:00
_ , 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 )
2026-08-12 12:56:22 +01:00
return err
}
// CreateAdminUser inserts a new global-admin account (full access, no domain
2026-08-13 08:07:19 +01:00
// 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).
2026-08-12 12:56:22 +01:00
func ( d * DB ) CreateAdminUser ( username , passwordHash string , mustChangePassword bool ) ( int64 , error ) {
2026-08-13 08:07:19 +01:00
res , err := d . Exec ( `INSERT INTO esrv_admin_users (username, password_hash, must_change_password, must_change_username, is_global_admin) VALUES (?, ?, ?, 0, 1)` ,
2026-08-12 12:56:22 +01:00
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 ()
2026-08-13 08:07:19 +01:00
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, ?)` ,
2026-08-12 12:56:22 +01:00
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
2026-08-13 08:07:19 +01:00
if err := rows . Scan ( & u . ID , & u . Username , & u . PasswordHash , & u . MustChangePassword , & u . MustChangeUsername , & u . TOTPSecret , & u . TOTPEnabled , & u . IsGlobalAdmin , & createdBy , & createdAt ); err != nil {
2026-08-12 12:56:22 +01:00
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 )
}
2026-08-13 08:07:19 +01:00
// 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.
2026-08-12 12:56:22 +01:00
func ( d * DB ) UpdateAdminCredentials ( id int64 , username , passwordHash string ) error {
2026-08-13 08:07:19 +01:00
_ , 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 )
2026-08-12 12:56:22 +01:00
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
}
2026-08-13 08:07:19 +01:00
// 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 ()
}
2026-08-12 12:56:22 +01:00
// --- 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
}