Phase 4 — P4-03/04: RBAC + user management #14
@@ -270,3 +270,56 @@ func (s *Server) handleAPIUserPatch(w stdhttp.ResponseWriter, r *stdhttp.Request
|
||||
})
|
||||
w.WriteHeader(stdhttp.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) handleAPIUserDisable(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
actor, _ := s.requireUser(r)
|
||||
id := chi.URLParam(r, "id")
|
||||
u, err := s.deps.Store.GetUserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "")
|
||||
return
|
||||
}
|
||||
if u.Role == store.RoleAdmin && u.DisabledAt == nil {
|
||||
n, _ := s.deps.Store.CountEnabledAdmins(r.Context())
|
||||
if n <= 1 {
|
||||
writeJSONError(w, stdhttp.StatusConflict, "last_admin", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := s.deps.Store.DisableUser(r.Context(), id, now); err != nil {
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
// Kick existing sessions so the user is bounced immediately.
|
||||
_, _ = s.deps.Store.DeleteSessionsByUserID(r.Context(), id)
|
||||
var actorID *string
|
||||
if actor != nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
||||
ID: ulid.Make().String(), UserID: actorID, Actor: "user",
|
||||
Action: "user.disabled", TargetKind: ptr("user"), TargetID: &id,
|
||||
TS: now,
|
||||
})
|
||||
w.WriteHeader(stdhttp.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) handleAPIUserEnable(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
actor, _ := s.requireUser(r)
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := s.deps.Store.EnableUser(r.Context(), id); err != nil {
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
var actorID *string
|
||||
if actor != nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
||||
ID: ulid.Make().String(), UserID: actorID, Actor: "user",
|
||||
Action: "user.enabled", TargetKind: ptr("user"), TargetID: &id,
|
||||
TS: time.Now().UTC(),
|
||||
})
|
||||
w.WriteHeader(stdhttp.StatusOK)
|
||||
}
|
||||
|
||||
@@ -255,6 +255,8 @@ func (s *Server) routes(r chi.Router) {
|
||||
r.Post("/api/users", s.handleAPIUserCreate)
|
||||
r.Get("/api/users/{id}", s.handleAPIUserGet)
|
||||
r.Patch("/api/users/{id}", s.handleAPIUserPatch)
|
||||
r.Post("/api/users/{id}/disable", s.handleAPIUserDisable)
|
||||
r.Post("/api/users/{id}/enable", s.handleAPIUserEnable)
|
||||
r.Post("/api/notifications/{id}/test", s.handleAPINotificationTest)
|
||||
|
||||
if s.deps.UI != nil {
|
||||
|
||||
@@ -174,3 +174,45 @@ func TestAPIUserPatchRejectsLastAdminDemote(t *testing.T) {
|
||||
t.Errorf("status: got %d want 409", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIUserDisable(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, _ := rawTestServerWithUI(t)
|
||||
adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
|
||||
makeUser(t, srv, "admin2", store.RoleAdmin) // satisfy last-admin guard
|
||||
target := makeUser(t, srv, "victim", store.RoleOperator)
|
||||
cookie := loginAs(t, srv, adminID)
|
||||
|
||||
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/users/"+target+"/disable", nil)
|
||||
req.AddCookie(cookie)
|
||||
res, err := stdhttp.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != stdhttp.StatusOK {
|
||||
t.Errorf("status: got %d", res.StatusCode)
|
||||
}
|
||||
u, _ := srv.deps.Store.GetUserByID(t.Context(), target)
|
||||
if u.DisabledAt == nil {
|
||||
t.Error("disabled_at not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIUserDisableRejectsLastAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, _ := rawTestServerWithUI(t)
|
||||
adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
|
||||
cookie := loginAs(t, srv, adminID)
|
||||
|
||||
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/users/"+adminID+"/disable", nil)
|
||||
req.AddCookie(cookie)
|
||||
res, err := stdhttp.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != stdhttp.StatusConflict {
|
||||
t.Errorf("status: got %d want 409", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user