feat: protect initial owner from role change and deletion

This commit is contained in:
2026-04-07 20:47:22 +02:00
parent 8b9de9e83d
commit c4171e5b87
6 changed files with 112 additions and 5 deletions
+3
View File
@@ -98,11 +98,14 @@ The **Owner** role has unrestricted access. In addition to all Admin permissions
#### Owner Protections
- **Initial owner is permanently protected**: The owner account created during installation cannot be deleted, and its role cannot be changed. This is enforced both server-side and in the UI.
- The last owner account cannot be deleted
- The owner can always access Admin Settings, even when MFA enforcement would otherwise redirect them (to prevent lockout)
- On first startup, the initial account is always created with the `owner` role
- If no owner exists (e.g., after a migration from an older version), the first admin is automatically promoted to owner
> **Note:** Existing installations are automatically migrated — the oldest owner (by ID) is marked as the initial owner during the database migration.
## Audit Log Visibility
The audit log has role-based filtering:
+43 -1
View File
@@ -240,7 +240,7 @@ func (s *Service) EnsureAdmin(username, email string) (bool, string, error) {
return false, "", fmt.Errorf("failed to hash password: %w", err)
}
_, err = s.db.Exec(
result, err := s.db.Exec(
`INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)`,
username, email, string(hash), "owner",
)
@@ -248,6 +248,11 @@ func (s *Service) EnsureAdmin(username, email string) (bool, string, error) {
return false, "", err
}
// Store the ID of the initial owner so it can never be deleted or downgraded.
if ownerID, idErr := result.LastInsertId(); idErr == nil {
s.markInitialOwner(ownerID)
}
// Mark initial setup as complete so the password is never regenerated.
s.markInitialSetupComplete()
@@ -262,6 +267,43 @@ func (s *Service) isInitialSetupComplete() bool {
return err == nil && val == "true"
}
// markInitialOwner stores the user ID of the initial owner in the settings table.
func (s *Service) markInitialOwner(userID int64) {
s.db.Exec(
`INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('initial_owner_id', ?, CURRENT_TIMESTAMP)`,
fmt.Sprintf("%d", userID),
)
}
// IsInitialOwner returns true if the given user ID is the initial owner
// created during installation. This owner cannot be deleted or downgraded.
func (s *Service) IsInitialOwner(userID int64) bool {
var val string
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'initial_owner_id'`).Scan(&val)
if err != nil {
return false
}
stored, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return false
}
return stored == userID
}
// GetInitialOwnerID returns the user ID of the initial owner, or 0 if not set.
func (s *Service) GetInitialOwnerID() int64 {
var val string
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'initial_owner_id'`).Scan(&val)
if err != nil {
return 0
}
id, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return 0
}
return id
}
// markInitialSetupComplete persists the initial-setup flag in the settings table.
func (s *Service) markInitialSetupComplete() {
s.db.Exec(
+20
View File
@@ -246,5 +246,25 @@ func (d *DB) migrate() error {
}
}
// Migration: backfill initial_owner_id for existing installations
{
var migCount int
d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = 'backfill_initial_owner_id'`).Scan(&migCount)
if migCount == 0 {
// Only set if not already present (new installs set it in EnsureAdmin)
var existing string
err := d.QueryRow(`SELECT value FROM settings WHERE key = 'initial_owner_id'`).Scan(&existing)
if err != nil || existing == "" {
// Pick the oldest owner (lowest ID) as the initial owner
var ownerID int64
err := d.QueryRow(`SELECT id FROM users WHERE role = 'owner' ORDER BY id ASC LIMIT 1`).Scan(&ownerID)
if err == nil && ownerID > 0 {
d.Exec(`INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('initial_owner_id', CAST(? AS TEXT), CURRENT_TIMESTAMP)`, ownerID)
}
}
d.Exec(`INSERT INTO _migrations (name) VALUES ('backfill_initial_owner_id')`)
}
}
return nil
}
+36 -4
View File
@@ -174,6 +174,10 @@ type PageData struct {
// Key Enforcement
EnforcementStatus map[string]string
// Initial Owner protection
IsInitialOwner bool
InitialOwnerID int64
}
// SystemInfo holds runtime system information for the settings page
@@ -653,6 +657,11 @@ func isOwner(role string) bool {
return role == "owner"
}
// getInitialOwnerID returns the user ID of the initial owner (0 if not set)
func (h *Handler) getInitialOwnerID() int64 {
return h.auth.GetInitialOwnerID()
}
func (h *Handler) getUserID(r *http.Request) int64 {
id, _ := strconv.ParseInt(r.Header.Get("X-User-ID"), 10, 64)
return id
@@ -1826,10 +1835,11 @@ func (h *Handler) handleUsers(w http.ResponseWriter, r *http.Request) {
}
data := &PageData{
Title: "User Management",
Active: "users",
User: user,
Users: users,
Title: "User Management",
Active: "users",
User: user,
Users: users,
InitialOwnerID: h.getInitialOwnerID(),
}
h.templates["users"].ExecuteTemplate(w, "base", data)
}
@@ -2002,6 +2012,7 @@ func (h *Handler) handleUserAction(w http.ResponseWriter, r *http.Request) {
User: user,
EditUser: targetUser,
PasswordPolicy: &policy,
IsInitialOwner: h.auth.IsInitialOwner(targetID),
}
h.templates["users_edit"].ExecuteTemplate(w, "base", data)
return
@@ -2014,6 +2025,22 @@ func (h *Handler) handleUserAction(w http.ResponseWriter, r *http.Request) {
newPassword := r.FormValue("password")
forceChange := r.FormValue("must_change_password") == "1"
// Initial Owner protection: role must remain "owner"
if h.auth.IsInitialOwner(targetID) && role != "owner" {
policy := h.auth.GetPasswordPolicy()
data := &PageData{
Title: "Edit User",
Active: "users",
User: user,
EditUser: targetUser,
PasswordPolicy: &policy,
IsInitialOwner: true,
Flash: &Flash{Type: "danger", Message: "The initial owner role cannot be changed. This account was created during installation and is permanently protected."},
}
h.templates["users_edit"].ExecuteTemplate(w, "base", data)
return
}
// Enforce role restrictions:
// - Admin can only assign "user" role
// - Only owner can assign "admin" or "owner"
@@ -2107,6 +2134,11 @@ func (h *Handler) handleUserAction(w http.ResponseWriter, r *http.Request) {
case "delete":
if r.Method == http.MethodPost {
// Initial Owner protection: cannot be deleted
if h.auth.IsInitialOwner(targetID) {
http.Redirect(w, r, "/users", http.StatusSeeOther)
return
}
// Owner protection: cannot self-delete
if targetID == userID {
http.Redirect(w, r, "/users", http.StatusSeeOther)
+2
View File
@@ -78,11 +78,13 @@
<a href="/users/{{.ID}}/edit" class="btn btn-sm btn-icon btn-outline-primary" title="Edit">
<i class="ti ti-edit"></i>
</a>
{{if ne .ID $.InitialOwnerID}}
<form method="POST" action="/users/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this user?')">
<button type="submit" class="btn btn-sm btn-icon btn-outline-danger" title="Delete">
<i class="ti ti-trash"></i>
</button>
</form>
{{end}}
</div>
</td>
</tr>
+8
View File
@@ -38,6 +38,13 @@
</div>
<div class="mb-3">
<label class="form-label required">Role</label>
{{if .IsInitialOwner}}
<select name="role" class="form-select" disabled>
<option value="owner" selected>Owner</option>
</select>
<input type="hidden" name="role" value="owner">
<small class="form-hint text-warning"><i class="ti ti-shield-lock"></i> The initial owner role cannot be changed. This account was created during installation and is permanently protected.</small>
{{else}}
<select name="role" class="form-select">
<option value="user" {{if eq .EditUser.Role "user"}}selected{{end}}>User</option>
{{with $.User}}
@@ -47,6 +54,7 @@
{{end}}
{{end}}
</select>
{{end}}
</div>
<div class="mb-3">
<label class="form-label">MFA Status</label>