feat: protect initial owner from role change and deletion
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user