Files
DoGaMa-serv/internal/web/admin_observability.go
T

258 lines
9.0 KiB
Go

package web
import (
"net/http"
"strconv"
"strings"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
)
func (s *server) requireRecentAdmin(w http.ResponseWriter, r *http.Request, api bool) (auth.User, bool) {
var user auth.User
var ok bool
if api {
user, ok = s.requireAPIUser(w, r, true)
} else {
var err error
user, err = s.currentUser(r)
ok = err == nil && user.Role == "admin"
if ok {
session, e := r.Cookie(sessionCookie)
ok = e == nil && s.parseForm(w, r) && s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token"))
}
}
if !ok {
return auth.User{}, false
}
if time.Since(user.AuthenticatedAt) > 10*time.Minute {
if api {
s.apiProblem(w, http.StatusForbidden, "reauthentication_required", "Recent authentication is required.")
} else {
s.problem(w, http.StatusForbidden, "Recent authentication is required.")
}
return auth.User{}, false
}
return user, true
}
func (s *server) recordAudit(r *http.Request, actor auth.User, action, outcome string, summary map[string]string) {
if s.audit == nil {
return
}
if err := s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, Action: action, Outcome: outcome, Summary: summary}); err != nil {
s.logger.Warn("audit recording failed", "event", "audit.record.failed")
}
}
func (s *server) auditList(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
f := audit.Filter{ActorID: r.URL.Query().Get("actor_id"), InstanceID: r.URL.Query().Get("instance_id"), Action: r.URL.Query().Get("action"), Outcome: r.URL.Query().Get("outcome")}
events, err := s.audit.List(r.Context(), f)
if err != nil {
s.apiProblem(w, 500, "audit_unavailable", "Audit events are unavailable.")
return
}
s.apiJSON(w, 200, map[string]any{"events": events})
}
func (s *server) auditPolicyGet(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
p, err := s.audit.Policy(r.Context())
if err != nil {
s.apiProblem(w, 500, "audit_unavailable", "Audit policy is unavailable.")
return
}
s.apiJSON(w, 200, p)
}
func (s *server) auditPolicyPut(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, true)
if !ok {
return
}
var p audit.Policy
if !s.decodeAPIJSON(w, r, &p) {
return
}
if err := s.audit.SetPolicy(r.Context(), p); err != nil {
s.recordAudit(r, actor, "audit.policy.update", "failed", map[string]string{"reason_code": "invalid_policy"})
s.apiProblem(w, 422, "invalid_policy", "The audit policy is invalid.")
return
}
s.recordAudit(r, actor, "audit.policy.update", "allowed", nil)
s.apiJSON(w, 200, p)
}
func (s *server) auditPurge(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, true)
if !ok {
return
}
var in struct {
Before string `json:"before"`
Confirm bool `json:"confirm"`
}
if !s.decodeAPIJSON(w, r, &in) || !in.Confirm {
s.apiProblem(w, 422, "confirmation_required", "Purge confirmation is required.")
return
}
before, err := time.Parse(time.RFC3339, in.Before)
if err != nil {
s.apiProblem(w, 422, "invalid_boundary", "The purge boundary is invalid.")
return
}
n, err := s.audit.Purge(r.Context(), before)
if err != nil {
s.apiProblem(w, 422, "purge_failed", "The audit purge failed.")
return
}
s.recordAudit(r, actor, "audit.purge", "allowed", map[string]string{"deleted_count": strconv.FormatInt(n, 10)})
s.apiJSON(w, 200, map[string]int64{"deleted_count": n})
}
func (s *server) notificationList(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
values, err := s.notifications.List(r.Context())
if err != nil {
s.apiProblem(w, 500, "channels_unavailable", "Notification channels are unavailable.")
return
}
s.apiJSON(w, 200, map[string]any{"channels": values})
}
func (s *server) notificationCreate(w http.ResponseWriter, r *http.Request) {
s.notificationUpsert(w, r, "")
}
func (s *server) notificationUpdate(w http.ResponseWriter, r *http.Request) {
s.notificationUpsert(w, r, r.PathValue("id"))
}
func (s *server) notificationUpsert(w http.ResponseWriter, r *http.Request, id string) {
actor, ok := s.requireRecentAdmin(w, r, true)
if !ok {
return
}
var in notification.Input
if !s.decodeAPIJSON(w, r, &in) {
return
}
value, err := s.notifications.Upsert(r.Context(), id, in)
if err != nil {
s.recordAudit(r, actor, "notification.channel.update", "failed", map[string]string{"channel_type": in.Type, "reason_code": "invalid_channel"})
s.apiProblem(w, 422, "invalid_channel", "The notification channel is invalid.")
return
}
s.recordAudit(r, actor, "notification.channel.update", "allowed", map[string]string{"target_id": value.ID, "channel_type": value.Type})
status := http.StatusOK
if id == "" {
status = http.StatusCreated
}
s.apiJSON(w, status, value)
}
func (s *server) notificationDelete(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, true)
if !ok {
return
}
if err := s.notifications.Delete(r.Context(), r.PathValue("id")); err != nil {
s.apiProblem(w, 500, "delete_failed", "The channel could not be deleted.")
return
}
s.recordAudit(r, actor, "notification.channel.delete", "allowed", map[string]string{"target_id": r.PathValue("id")})
w.WriteHeader(http.StatusNoContent)
}
func (s *server) notificationTest(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, true)
if !ok {
return
}
if !s.requireEmptyBody(w, r) {
return
}
if err := s.notifications.Test(r.Context(), r.PathValue("id")); err != nil {
s.apiProblem(w, 404, "channel_not_found", "The channel was not found.")
return
}
s.recordAudit(r, actor, "notification.channel.test", "allowed", map[string]string{"target_id": r.PathValue("id"), "event_type": "notification.test"})
s.apiJSON(w, 202, map[string]string{"status": "queued"})
}
func (s *server) notificationForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
config := map[string]string{"url": r.FormValue("url"), "signing_secret": r.FormValue("signing_secret"), "host": r.FormValue("host"), "port": r.FormValue("port"), "username": r.FormValue("smtp_username"), "password": r.FormValue("smtp_password"), "from": r.FormValue("from"), "to": r.FormValue("to")}
value, err := s.notifications.Upsert(r.Context(), "", notification.Input{Name: r.FormValue("name"), Type: r.FormValue("type"), Enabled: true, Events: strings.Fields(r.FormValue("events")), Config: config})
if err != nil {
s.problem(w, 422, "Invalid notification channel.")
return
}
s.recordAudit(r, actor, "notification.channel.update", "allowed", map[string]string{"target_id": value.ID, "channel_type": value.Type})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) notificationTestForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
if err := s.notifications.Test(r.Context(), r.PathValue("id")); err != nil {
s.problem(w, 404, "Notification channel not found.")
return
}
s.recordAudit(r, actor, "notification.channel.test", "allowed", map[string]string{"target_id": r.PathValue("id"), "event_type": "notification.test"})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) notificationDeleteForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
if err := s.notifications.Delete(r.Context(), r.PathValue("id")); err != nil {
s.problem(w, 500, "Notification channel could not be deleted.")
return
}
s.recordAudit(r, actor, "notification.channel.delete", "allowed", map[string]string{"target_id": r.PathValue("id")})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) auditPolicyForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
days, _ := strconv.Atoi(r.FormValue("retention_days"))
maximum, _ := strconv.Atoi(r.FormValue("maximum_count"))
if err := s.audit.SetPolicy(r.Context(), audit.Policy{RetentionDays: days, MaximumCount: maximum}); err != nil {
s.problem(w, 422, "Invalid audit policy.")
return
}
s.recordAudit(r, actor, "audit.policy.update", "allowed", nil)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) auditPurgeForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
if r.FormValue("confirm") != "yes" {
s.problem(w, 422, "Purge confirmation is required.")
return
}
before, err := time.Parse("2006-01-02", r.FormValue("before"))
if err != nil {
s.problem(w, 422, "Invalid purge date.")
return
}
n, err := s.audit.Purge(r.Context(), before)
if err != nil {
s.problem(w, 422, "Audit purge failed.")
return
}
s.recordAudit(r, actor, "audit.purge", "allowed", map[string]string{"deleted_count": strconv.FormatInt(n, 10)})
http.Redirect(w, r, "/", http.StatusSeeOther)
}