Skip to content

Commit c4f6156

Browse files
authored
fix(api): gate backup-config + alert-history routes by role/access (#23)
- PUT/DELETE /api/backups/configs/{id}, POST /api/backups/restore/{id}, POST /api/backups/configs/{id}/run now use canMutateForApp instead of checkAppAccessByID, so viewers and manage-without-grant are rejected. - DELETE /api/alerts/history is now super_admin-only. - GET /api/alerts/history filters rows to apps the caller has access to for non-super_admin (global rows hidden).
1 parent 5a9fd62 commit c4f6156

5 files changed

Lines changed: 240 additions & 5 deletions

File tree

internal/api/alerts.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,11 @@ func (s *Server) handleDeleteAlertRule(w http.ResponseWriter, r *http.Request) {
472472
// --- Alert History ---
473473

474474
func (s *Server) handleListAlertHistory(w http.ResponseWriter, r *http.Request) {
475+
user := GetAuthUser(r)
476+
if user == nil {
477+
http.Error(w, "unauthorized", http.StatusUnauthorized)
478+
return
479+
}
475480
var ruleID *int64
476481
if v := r.URL.Query().Get("rule_id"); v != "" {
477482
id, err := strconv.ParseInt(v, 10, 64)
@@ -496,6 +501,27 @@ func (s *Server) handleListAlertHistory(w http.ResponseWriter, r *http.Request)
496501
if hist == nil {
497502
hist = []store.AlertHistory{}
498503
}
504+
// Filter to apps the caller has access to (super_admin sees everything).
505+
if user.Role != "super_admin" {
506+
filtered := hist[:0]
507+
accessCache := map[string]bool{}
508+
for _, h := range hist {
509+
if h.AppSlug == "" {
510+
// global rows visible only to super_admin
511+
continue
512+
}
513+
ok, present := accessCache[h.AppSlug]
514+
if !present {
515+
granted, _ := s.store.HasAppAccess(user.ID, h.AppSlug)
516+
accessCache[h.AppSlug] = granted
517+
ok = granted
518+
}
519+
if ok {
520+
filtered = append(filtered, h)
521+
}
522+
}
523+
hist = filtered
524+
}
499525
w.Header().Set("Content-Type", "application/json")
500526
json.NewEncoder(w).Encode(hist)
501527
}

internal/api/alerts_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,3 +768,43 @@ func TestEditRuleAfterFired(t *testing.T) {
768768
}
769769
}
770770
}
771+
772+
// --- RBAC tests for DELETE /api/alerts/history ---
773+
774+
func TestClearAlertHistory_RequiresSuperAdmin(t *testing.T) {
775+
srv, st, adminCookie := setupUserTestServer(t)
776+
777+
// super_admin: 200
778+
req := authedRequest(t, http.MethodDelete, "/api/alerts/history", nil, adminCookie)
779+
w := httptest.NewRecorder()
780+
srv.Handler().ServeHTTP(w, req)
781+
if w.Code != http.StatusOK {
782+
t.Fatalf("super_admin status = %d, want 200; body: %s", w.Code, w.Body.String())
783+
}
784+
785+
// manage: 403
786+
manageCookie := loginAs(t, srv, st, "mgr", "managepass1", "manage")
787+
req = authedRequest(t, http.MethodDelete, "/api/alerts/history", nil, manageCookie)
788+
w = httptest.NewRecorder()
789+
srv.Handler().ServeHTTP(w, req)
790+
if w.Code != http.StatusForbidden {
791+
t.Fatalf("manage status = %d, want 403", w.Code)
792+
}
793+
794+
// viewer: 403
795+
viewerCookie := loginAs(t, srv, st, "v1", "viewerpass1", "viewer")
796+
req = authedRequest(t, http.MethodDelete, "/api/alerts/history", nil, viewerCookie)
797+
w = httptest.NewRecorder()
798+
srv.Handler().ServeHTTP(w, req)
799+
if w.Code != http.StatusForbidden {
800+
t.Fatalf("viewer status = %d, want 403", w.Code)
801+
}
802+
803+
// unauth: 401
804+
req = httptest.NewRequest(http.MethodDelete, "/api/alerts/history", nil)
805+
w = httptest.NewRecorder()
806+
srv.Handler().ServeHTTP(w, req)
807+
if w.Code != http.StatusUnauthorized {
808+
t.Fatalf("unauth status = %d, want 401", w.Code)
809+
}
810+
}

internal/api/backups.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ func (s *Server) handleUpdateBackupConfig(w http.ResponseWriter, r *http.Request
119119
http.Error(w, "backup config not found", http.StatusNotFound)
120120
return
121121
}
122-
if !s.checkAppAccessByID(w, r, existing.AppID) {
122+
if !s.canMutateForApp(w, r, &existing.AppID) {
123123
return
124124
}
125125

@@ -189,7 +189,7 @@ func (s *Server) handleDeleteBackupConfig(w http.ResponseWriter, r *http.Request
189189
http.Error(w, "backup config not found", http.StatusNotFound)
190190
return
191191
}
192-
if !s.checkAppAccessByID(w, r, existing.AppID) {
192+
if !s.canMutateForApp(w, r, &existing.AppID) {
193193
return
194194
}
195195

@@ -314,7 +314,7 @@ func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
314314
http.Error(w, "backup config not found", http.StatusNotFound)
315315
return
316316
}
317-
if !s.checkAppAccessByID(w, r, cfg.AppID) {
317+
if !s.canMutateForApp(w, r, &cfg.AppID) {
318318
return
319319
}
320320

@@ -403,7 +403,7 @@ func (s *Server) handleTriggerBackupConfig(w http.ResponseWriter, r *http.Reques
403403
http.Error(w, "backup config not found", http.StatusNotFound)
404404
return
405405
}
406-
if !s.checkAppAccessByID(w, r, cfg.AppID) {
406+
if !s.canMutateForApp(w, r, &cfg.AppID) {
407407
return
408408
}
409409

internal/api/backups_test.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,175 @@ func TestBackupSummary(t *testing.T) {
186186
}
187187
}
188188

189+
// --- RBAC tests for /api/backups/configs/{id} (PUT/DELETE) ---
190+
191+
// seedBackupConfigForApp creates an app + a backup config and returns the cfg ID.
192+
func seedBackupConfigForApp(t *testing.T, st *store.Store, slug string) int64 {
193+
t.Helper()
194+
if err := st.UpsertApp(&store.App{Name: slug, Slug: slug, ComposePath: "/tmp/" + slug + ".yml", Status: "running"}, nil); err != nil {
195+
t.Fatalf("upsert app %s: %v", slug, err)
196+
}
197+
app, err := st.GetAppBySlug(slug)
198+
if err != nil {
199+
t.Fatalf("get app %s: %v", slug, err)
200+
}
201+
cfg := &store.BackupConfig{
202+
AppID: app.ID,
203+
Strategy: "postgres",
204+
Target: "local",
205+
ScheduleCron: "0 2 * * *",
206+
RetentionMode: "count",
207+
RetentionCount: 3,
208+
}
209+
if err := st.CreateBackupConfig(cfg); err != nil {
210+
t.Fatalf("create cfg: %v", err)
211+
}
212+
return cfg.ID
213+
}
214+
215+
func TestUpdateBackupConfig_ManageWithGrant(t *testing.T) {
216+
srv, st, _ := setupUserTestServer(t)
217+
cfgID := seedBackupConfigForApp(t, st, "alpha")
218+
app, _ := st.GetAppBySlug("alpha")
219+
220+
manageCookie := loginAs(t, srv, st, "mgr", "managepass1", "manage")
221+
mgr, _ := st.GetUserByUsername("mgr")
222+
if err := st.GrantAppAccess(mgr.ID, app.ID); err != nil {
223+
t.Fatalf("grant: %v", err)
224+
}
225+
226+
body := map[string]any{
227+
"strategy": "postgres",
228+
"target": "local",
229+
"schedule_cron": "0 3 * * *",
230+
"retention_mode": "count",
231+
"retention_count": 7,
232+
}
233+
req := authedRequest(t, http.MethodPut, fmt.Sprintf("/api/backups/configs/%d", cfgID), body, manageCookie)
234+
w := httptest.NewRecorder()
235+
srv.Handler().ServeHTTP(w, req)
236+
if w.Code != http.StatusOK {
237+
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
238+
}
239+
}
240+
241+
func TestUpdateBackupConfig_ManageWithoutGrant(t *testing.T) {
242+
srv, st, _ := setupUserTestServer(t)
243+
cfgID := seedBackupConfigForApp(t, st, "alpha")
244+
245+
manageCookie := loginAs(t, srv, st, "mgr", "managepass1", "manage")
246+
247+
body := map[string]any{"strategy": "postgres", "target": "local"}
248+
req := authedRequest(t, http.MethodPut, fmt.Sprintf("/api/backups/configs/%d", cfgID), body, manageCookie)
249+
w := httptest.NewRecorder()
250+
srv.Handler().ServeHTTP(w, req)
251+
if w.Code != http.StatusNotFound {
252+
t.Fatalf("status = %d, want 404", w.Code)
253+
}
254+
}
255+
256+
func TestUpdateBackupConfig_ViewerForbidden(t *testing.T) {
257+
srv, st, _ := setupUserTestServer(t)
258+
cfgID := seedBackupConfigForApp(t, st, "alpha")
259+
app, _ := st.GetAppBySlug("alpha")
260+
261+
viewerCookie := loginAs(t, srv, st, "v1", "viewerpass1", "viewer")
262+
v, _ := st.GetUserByUsername("v1")
263+
if err := st.GrantAppAccess(v.ID, app.ID); err != nil {
264+
t.Fatalf("grant: %v", err)
265+
}
266+
267+
body := map[string]any{"strategy": "postgres", "target": "local"}
268+
req := authedRequest(t, http.MethodPut, fmt.Sprintf("/api/backups/configs/%d", cfgID), body, viewerCookie)
269+
w := httptest.NewRecorder()
270+
srv.Handler().ServeHTTP(w, req)
271+
if w.Code != http.StatusForbidden {
272+
t.Fatalf("status = %d, want 403", w.Code)
273+
}
274+
}
275+
276+
func TestUpdateBackupConfig_SuperAdmin(t *testing.T) {
277+
srv, st, adminCookie := setupUserTestServer(t)
278+
cfgID := seedBackupConfigForApp(t, st, "alpha")
279+
280+
body := map[string]any{
281+
"strategy": "postgres",
282+
"target": "local",
283+
"schedule_cron": "0 4 * * *",
284+
"retention_mode": "count",
285+
"retention_count": 9,
286+
}
287+
req := authedRequest(t, http.MethodPut, fmt.Sprintf("/api/backups/configs/%d", cfgID), body, adminCookie)
288+
w := httptest.NewRecorder()
289+
srv.Handler().ServeHTTP(w, req)
290+
if w.Code != http.StatusOK {
291+
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
292+
}
293+
}
294+
295+
func TestDeleteBackupConfig_ManageWithGrant(t *testing.T) {
296+
srv, st, _ := setupUserTestServer(t)
297+
cfgID := seedBackupConfigForApp(t, st, "alpha")
298+
app, _ := st.GetAppBySlug("alpha")
299+
300+
manageCookie := loginAs(t, srv, st, "mgr", "managepass1", "manage")
301+
mgr, _ := st.GetUserByUsername("mgr")
302+
if err := st.GrantAppAccess(mgr.ID, app.ID); err != nil {
303+
t.Fatalf("grant: %v", err)
304+
}
305+
306+
req := authedRequest(t, http.MethodDelete, fmt.Sprintf("/api/backups/configs/%d", cfgID), nil, manageCookie)
307+
w := httptest.NewRecorder()
308+
srv.Handler().ServeHTTP(w, req)
309+
if w.Code != http.StatusNoContent {
310+
t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String())
311+
}
312+
}
313+
314+
func TestDeleteBackupConfig_ManageWithoutGrant(t *testing.T) {
315+
srv, st, _ := setupUserTestServer(t)
316+
cfgID := seedBackupConfigForApp(t, st, "alpha")
317+
318+
manageCookie := loginAs(t, srv, st, "mgr", "managepass1", "manage")
319+
320+
req := authedRequest(t, http.MethodDelete, fmt.Sprintf("/api/backups/configs/%d", cfgID), nil, manageCookie)
321+
w := httptest.NewRecorder()
322+
srv.Handler().ServeHTTP(w, req)
323+
if w.Code != http.StatusNotFound {
324+
t.Fatalf("status = %d, want 404", w.Code)
325+
}
326+
}
327+
328+
func TestDeleteBackupConfig_ViewerForbidden(t *testing.T) {
329+
srv, st, _ := setupUserTestServer(t)
330+
cfgID := seedBackupConfigForApp(t, st, "alpha")
331+
app, _ := st.GetAppBySlug("alpha")
332+
333+
viewerCookie := loginAs(t, srv, st, "v1", "viewerpass1", "viewer")
334+
v, _ := st.GetUserByUsername("v1")
335+
if err := st.GrantAppAccess(v.ID, app.ID); err != nil {
336+
t.Fatalf("grant: %v", err)
337+
}
338+
339+
req := authedRequest(t, http.MethodDelete, fmt.Sprintf("/api/backups/configs/%d", cfgID), nil, viewerCookie)
340+
w := httptest.NewRecorder()
341+
srv.Handler().ServeHTTP(w, req)
342+
if w.Code != http.StatusForbidden {
343+
t.Fatalf("status = %d, want 403", w.Code)
344+
}
345+
}
346+
347+
func TestUpdateBackupConfig_NotFound(t *testing.T) {
348+
srv, _, adminCookie := setupUserTestServer(t)
349+
body := map[string]any{"strategy": "postgres", "target": "local"}
350+
req := authedRequest(t, http.MethodPut, "/api/backups/configs/99999", body, adminCookie)
351+
w := httptest.NewRecorder()
352+
srv.Handler().ServeHTTP(w, req)
353+
if w.Code != http.StatusNotFound {
354+
t.Fatalf("status = %d, want 404", w.Code)
355+
}
356+
}
357+
189358
func TestTriggerBackupConfig(t *testing.T) {
190359
srv, s := newTestServer(t)
191360
s.UpsertApp(&store.App{Name: "myapp", Slug: "myapp", ComposePath: "/tmp/1.yml", Status: "running"}, nil)

internal/api/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ func (s *Server) routes() {
294294

295295
// Alert history
296296
s.mux.Handle("GET /api/alerts/history", s.authMiddleware(http.HandlerFunc(s.handleListAlertHistory)))
297-
s.mux.Handle("DELETE /api/alerts/history", s.authMiddleware(http.HandlerFunc(s.handleClearAlertHistory)))
297+
s.mux.Handle("DELETE /api/alerts/history", s.authMiddleware(s.superAdminMiddleware(http.HandlerFunc(s.handleClearAlertHistory))))
298298

299299
// Backup configs
300300
s.mux.Handle("GET /api/apps/{slug}/backups/configs", s.authMiddleware(s.appAccessMiddleware(http.HandlerFunc(s.handleListBackupConfigs))))

0 commit comments

Comments
 (0)