Skip to content

Commit 3ac3c84

Browse files
authored
Merge pull request #27 from AppsGanin/feat/admin-roles-and-audit
feat(admins): multiple admins with roles and an admin audit trail
2 parents 4561544 + 35c18ba commit 3ac3c84

38 files changed

Lines changed: 3518 additions & 382 deletions

README.md

Lines changed: 114 additions & 192 deletions
Large diffs are not rendered by default.

cmd/rospanel/service.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@ func paymentPollLoop(mgr *core.Manager) {
329329
func retentionLoop(mgr *core.Manager) {
330330
sweep := func() {
331331
mgr.PurgeOldEvents()
332+
mgr.PurgeOldAdminAudit()
332333
mgr.PurgeOldConnections()
333334
}
334335
sweep() // sweep once at boot, then on the timer
@@ -423,15 +424,15 @@ func bootstrapPanel(st *store.Store) (string, error) {
423424
if err != nil {
424425
return "", err
425426
}
426-
if _, err := st.CreateAdmin("admin", hash); err != nil {
427-
return "", err
428-
}
429-
// Gate the whole authed API on a password change: until the default password
430-
// is replaced, requireAuth lets through only the password-change/restore
431-
// endpoints. This makes the change server-enforced, not just a wizard prompt
427+
// Whoever installs the panel owns it: the bootstrap account is the one role
428+
// that can manage the admin roster, and it cannot be deleted afterwards.
429+
//
430+
// It is created gated on a password change: until the default password is
431+
// replaced, requireAuth lets through only the password-change/restore
432+
// endpoints. That makes the change server-enforced, not just a wizard prompt
432433
// the SPA happens to show, so a leaked secret path before first setup can't be
433434
// driven with admin/admin.
434-
if err := st.SetMustChangePassword(true); err != nil {
435+
if _, err := st.CreateAdmin("admin", hash, model.RoleOwner, true); err != nil {
435436
return "", err
436437
}
437438
printFirstRunBanner(set.Host, secret, "admin", password)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package core
2+
3+
import (
4+
"time"
5+
6+
"github.com/AppsGanin/rospanel/internal/model"
7+
"github.com/AppsGanin/rospanel/internal/store"
8+
)
9+
10+
// The admin trail — see model/admin_audit.go for what it holds and why it isn't the
11+
// user journal. Writes come from the HTTP layer (one middleware over the panel
12+
// routes), not from here: the thing worth recording is "an admin called this route
13+
// and it succeeded", which only the router knows.
14+
//
15+
// Like the user journal, auditing is best-effort: a failed write is logged and
16+
// swallowed. Losing an audit row must never fail the operation that produced it.
17+
18+
// AddAdminAudit records one event.
19+
func (m *Manager) AddAdminAudit(ev model.AdminAudit) {
20+
if ev.CreatedAt == 0 {
21+
ev.CreatedAt = time.Now().Unix()
22+
}
23+
if err := m.store.AddAdminAudit(ev); err != nil {
24+
logErr("admin audit: write failed", "action", ev.Action, "err", err)
25+
}
26+
}
27+
28+
// AdminAudit returns the admin trail, newest first.
29+
func (m *Manager) AdminAudit(f store.AdminAuditFilter) ([]model.AdminAudit, error) {
30+
f.Limit = EventPageLimit(f.Limit)
31+
return m.store.ListAdminAudit(f)
32+
}
33+
34+
// PurgeOldAdminAudit drops trail rows past the retention window. Called from the
35+
// same slow timer as the user journal's sweep.
36+
func (m *Manager) PurgeOldAdminAudit() {
37+
cutoff := time.Now().AddDate(0, 0, -model.AdminAuditRetentionDays).Unix()
38+
n, err := m.store.PurgeAdminAudit(cutoff)
39+
if err != nil {
40+
logErr("admin audit: retention sweep failed", "err", err)
41+
return
42+
}
43+
if n > 0 {
44+
logInfo("admin audit: old events purged",
45+
"count", n, "older_than_days", model.AdminAuditRetentionDays)
46+
}
47+
}

internal/core/manager_admins.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package core
2+
3+
import (
4+
"errors"
5+
"log/slog"
6+
"regexp"
7+
"strings"
8+
9+
"github.com/AppsGanin/rospanel/internal/auth"
10+
"github.com/AppsGanin/rospanel/internal/model"
11+
"github.com/AppsGanin/rospanel/internal/store"
12+
)
13+
14+
// The admin roster. Only the owner reaches any of this (the routes are gated); the
15+
// rules below are the ones that survive even a legitimate owner making a mistake:
16+
// the owner can neither delete themselves nor be deleted by anyone else, so the
17+
// panel can never end up with nobody who can manage it.
18+
19+
var adminNameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{3,32}$`)
20+
21+
// minAdminPassword mirrors ChangeAdminPassword's floor — an assigned password is
22+
// held to the same bar as a chosen one.
23+
const minAdminPassword = 8
24+
25+
// ListAdmins returns the admin roster.
26+
func (m *Manager) ListAdmins() ([]model.Admin, error) {
27+
return m.store.ListAdmins()
28+
}
29+
30+
// CreateAdmin adds an account with a role and a password the owner picked. The
31+
// account is gated on a password change at first login: a password chosen by someone
32+
// else and delivered over a chat window is a bootstrap credential, not a permanent
33+
// one.
34+
func (m *Manager) CreateAdmin(username, password, role string) (model.Admin, error) {
35+
username = strings.TrimSpace(username)
36+
if !adminNameRe.MatchString(username) {
37+
return model.Admin{}, invalid("логин: 3–32 символа, латиница, цифры, точка, дефис или подчёркивание")
38+
}
39+
if !model.GrantableRole(role) {
40+
return model.Admin{}, invalid("неизвестная роль %q", role)
41+
}
42+
if len(password) < minAdminPassword {
43+
return model.Admin{}, invalid("пароль должен быть не короче %d символов", minAdminPassword)
44+
}
45+
hash, err := auth.HashPassword(password)
46+
if err != nil {
47+
return model.Admin{}, err
48+
}
49+
id, err := m.store.CreateAdmin(username, hash, role, true)
50+
if err != nil {
51+
return model.Admin{}, invalid("не удалось создать администратора (логин уже занят?)")
52+
}
53+
slog.Info("admin roster: created", "admin", username, "role", role, "id", id)
54+
return m.store.GetAdmin(id)
55+
}
56+
57+
// DeleteAdmin removes an account. Deleting it revokes its sessions too (the
58+
// admin_sessions rows cascade), so a colleague who is let go loses the panel on
59+
// their next request, not when their cookie happens to expire.
60+
func (m *Manager) DeleteAdmin(actorID, targetID int64) error {
61+
target, err := m.rosterTarget(actorID, targetID, "удалить")
62+
if err != nil {
63+
return err
64+
}
65+
if err := m.store.DeleteAdmin(targetID); err != nil {
66+
return err
67+
}
68+
slog.Info("admin roster: deleted", "admin", target.Username, "id", targetID)
69+
return nil
70+
}
71+
72+
// SetAdminRole moves an account between roles.
73+
func (m *Manager) SetAdminRole(actorID, targetID int64, role string) error {
74+
target, err := m.rosterTarget(actorID, targetID, "изменить")
75+
if err != nil {
76+
return err
77+
}
78+
if !model.GrantableRole(role) {
79+
return invalid("неизвестная роль %q", role)
80+
}
81+
if err := m.store.SetAdminRole(targetID, role); err != nil {
82+
return err
83+
}
84+
slog.Info("admin roster: role changed", "admin", target.Username, "role", role)
85+
return nil
86+
}
87+
88+
// ResetAdminPassword assigns a new password to another admin — for when a colleague
89+
// is locked out. Like a freshly created account it is gated on a change at first
90+
// login, and every session that account had is revoked: whoever was using the old
91+
// password is out.
92+
func (m *Manager) ResetAdminPassword(actorID, targetID int64, password string) error {
93+
target, err := m.rosterTarget(actorID, targetID, "сбросить пароль")
94+
if err != nil {
95+
return err
96+
}
97+
if len(password) < minAdminPassword {
98+
return invalid("пароль должен быть не короче %d символов", minAdminPassword)
99+
}
100+
hash, err := auth.HashPassword(password)
101+
if err != nil {
102+
return err
103+
}
104+
if err := m.store.UpdateAdminPassword(targetID, hash, true); err != nil {
105+
return err
106+
}
107+
if err := m.store.DeleteSessionsForAdmin(targetID); err != nil {
108+
return err
109+
}
110+
slog.Info("admin roster: password reset", "admin", target.Username)
111+
return nil
112+
}
113+
114+
// rosterTarget resolves the admin an owner is acting on and rejects the two moves
115+
// that would strand the panel: acting on the owner (there is exactly one, and it
116+
// must remain) and acting on yourself through the roster (your own login and
117+
// password live in the profile dialog, which re-verifies the current password —
118+
// the roster does not).
119+
func (m *Manager) rosterTarget(actorID, targetID int64, verb string) (model.Admin, error) {
120+
target, err := m.store.GetAdmin(targetID)
121+
if errors.Is(err, store.ErrAdminNotFound) {
122+
return model.Admin{}, invalid("администратор не найден")
123+
}
124+
if err != nil {
125+
return model.Admin{}, err
126+
}
127+
if target.Role == model.RoleOwner {
128+
return model.Admin{}, invalid("нельзя %s владельца панели", verb)
129+
}
130+
if target.ID == actorID {
131+
return model.Admin{}, invalid("нельзя %s собственную учётную запись", verb)
132+
}
133+
return target, nil
134+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package core
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
"testing"
7+
"time"
8+
9+
"github.com/AppsGanin/rospanel/internal/auth"
10+
"github.com/AppsGanin/rospanel/internal/model"
11+
"github.com/AppsGanin/rospanel/internal/store"
12+
)
13+
14+
// rosterManager returns a manager whose panel is already owned, mirroring a real
15+
// install: bootstrap creates the owner, everyone else is added through the roster.
16+
func rosterManager(t *testing.T) (*Manager, int64) {
17+
t.Helper()
18+
st, err := store.Open(filepath.Join(t.TempDir(), "roster.db"))
19+
if err != nil {
20+
t.Fatalf("open store: %v", err)
21+
}
22+
t.Cleanup(func() { st.Close() })
23+
24+
hash, err := auth.HashPassword("owner-password")
25+
if err != nil {
26+
t.Fatalf("hash: %v", err)
27+
}
28+
ownerID, err := st.CreateAdmin("owner", hash, model.RoleOwner, false)
29+
if err != nil {
30+
t.Fatalf("create owner: %v", err)
31+
}
32+
return &Manager{store: st}, ownerID
33+
}
34+
35+
func TestCreateAdminGatesTheAssignedPassword(t *testing.T) {
36+
m, _ := rosterManager(t)
37+
38+
a, err := m.CreateAdmin("support", "temp-password", model.RoleOperator)
39+
if err != nil {
40+
t.Fatalf("create: %v", err)
41+
}
42+
if a.Role != model.RoleOperator {
43+
t.Errorf("role = %q, want %q", a.Role, model.RoleOperator)
44+
}
45+
// The owner picked this password and sent it over a chat window: it is a
46+
// bootstrap credential, and the account is useless until it's replaced.
47+
if !a.MustChangePassword {
48+
t.Error("a password chosen by someone else did not raise the change gate")
49+
}
50+
51+
// And choosing their own lifts it.
52+
if err := m.ChangeAdminPassword(a.ID, "chosen-by-them"); err != nil {
53+
t.Fatalf("change password: %v", err)
54+
}
55+
got, err := m.store.GetAdmin(a.ID)
56+
if err != nil {
57+
t.Fatalf("get: %v", err)
58+
}
59+
if got.MustChangePassword {
60+
t.Error("gate still up after the admin picked their own password")
61+
}
62+
}
63+
64+
func TestCreateAdminRejectsBadInput(t *testing.T) {
65+
m, _ := rosterManager(t)
66+
67+
if _, err := m.CreateAdmin("support", "temp-password", model.RoleOperator); err != nil {
68+
t.Fatalf("seed: %v", err)
69+
}
70+
71+
cases := []struct {
72+
name, username, password, role string
73+
}{
74+
{"duplicate login", "support", "temp-password", model.RoleAdmin},
75+
{"short password", "helper", "short", model.RoleAdmin},
76+
{"login too short", "ab", "temp-password", model.RoleAdmin},
77+
{"login with spaces", "the helper", "temp-password", model.RoleAdmin},
78+
{"unknown role", "helper", "temp-password", "superuser"},
79+
// The one that matters: no path may quietly produce a second owner.
80+
{"owner is not grantable", "helper", "temp-password", model.RoleOwner},
81+
}
82+
for _, tc := range cases {
83+
t.Run(tc.name, func(t *testing.T) {
84+
if _, err := m.CreateAdmin(tc.username, tc.password, tc.role); err == nil {
85+
t.Fatal("accepted, want rejected")
86+
}
87+
})
88+
}
89+
admins, err := m.ListAdmins()
90+
if err != nil {
91+
t.Fatalf("list: %v", err)
92+
}
93+
if len(admins) != 2 { // owner + support, nothing the rejected calls left behind
94+
t.Fatalf("roster grew to %d, want 2", len(admins))
95+
}
96+
}
97+
98+
// The two moves that would strand the panel: removing the only account that can
99+
// manage the roster, or the owner removing themselves.
100+
func TestRosterCannotStrandThePanel(t *testing.T) {
101+
m, ownerID := rosterManager(t)
102+
103+
admin, err := m.CreateAdmin("colleague", "temp-password", model.RoleAdmin)
104+
if err != nil {
105+
t.Fatalf("create: %v", err)
106+
}
107+
108+
if err := m.DeleteAdmin(ownerID, ownerID); err == nil {
109+
t.Error("the owner deleted themselves")
110+
}
111+
if err := m.DeleteAdmin(admin.ID, ownerID); err == nil {
112+
t.Error("an admin deleted the owner")
113+
}
114+
if err := m.SetAdminRole(ownerID, ownerID, model.RoleOperator); err == nil {
115+
t.Error("the owner demoted themselves to operator")
116+
}
117+
if err := m.ResetAdminPassword(ownerID, ownerID, "new-password"); err == nil {
118+
t.Error("the owner reset their own password through the roster (bypassing re-auth)")
119+
}
120+
121+
// The owner survived all of it and is still the owner.
122+
owner, err := m.store.GetAdmin(ownerID)
123+
if err != nil {
124+
t.Fatalf("get owner: %v", err)
125+
}
126+
if owner.Role != model.RoleOwner {
127+
t.Fatalf("owner role = %q, want %q", owner.Role, model.RoleOwner)
128+
}
129+
}
130+
131+
func TestDeleteAdminRejectsUnknownID(t *testing.T) {
132+
m, ownerID := rosterManager(t)
133+
err := m.DeleteAdmin(ownerID, 4242)
134+
if err == nil {
135+
t.Fatal("deleted an admin that does not exist")
136+
}
137+
if !strings.Contains(err.Error(), "не найден") {
138+
t.Errorf("err = %v, want a not-found message", err)
139+
}
140+
}
141+
142+
// Resetting a colleague's password is what you do when they are locked out — or
143+
// when you no longer trust them. Either way every session they had must die with
144+
// the old password, or the reset achieves nothing against a stolen cookie.
145+
func TestResetAdminPasswordRevokesSessionsAndRegates(t *testing.T) {
146+
m, ownerID := rosterManager(t)
147+
148+
admin, err := m.CreateAdmin("colleague", "temp-password", model.RoleAdmin)
149+
if err != nil {
150+
t.Fatalf("create: %v", err)
151+
}
152+
if err := m.ChangeAdminPassword(admin.ID, "chosen-by-them"); err != nil {
153+
t.Fatalf("change password: %v", err)
154+
}
155+
token, err := m.store.CreateSession(admin.ID, time.Hour)
156+
if err != nil {
157+
t.Fatalf("session: %v", err)
158+
}
159+
160+
if err := m.ResetAdminPassword(ownerID, admin.ID, "reset-by-owner"); err != nil {
161+
t.Fatalf("reset: %v", err)
162+
}
163+
if _, ok := m.store.LookupSession(token); ok {
164+
t.Error("session survived a password reset")
165+
}
166+
got, err := m.store.GetAdmin(admin.ID)
167+
if err != nil {
168+
t.Fatalf("get: %v", err)
169+
}
170+
if !got.MustChangePassword {
171+
t.Error("an owner-assigned password did not raise the change gate")
172+
}
173+
if err := m.ResetAdminPassword(ownerID, admin.ID, "short"); err == nil {
174+
t.Error("accepted a password below the minimum length")
175+
}
176+
}

0 commit comments

Comments
 (0)