Scope log retention deletion to selected hosts, not the whole table
api/logretention no longer deletes wholesale by age alone: a new
GET /logs/retention/hosts lists every host with matching records (plus
any configured retention floor), and preview/delete now require an
explicit, non-empty host list -- there is no "omitted host means every
host" shortcut server-side. Store's count/delete statements are
host-scoped (host IN (...)); Handler.partitionHosts checks the floor
per host instead of one global max, so a floor on one host never
blocks acting on other hosts requested in the same call. A request
that ends up fully or partially blocked still returns 200 with
blocked_hosts explaining why, rather than rejecting the whole call.
Settings' Log retention section is a host picker now: checkboxes with
per-host counts and a "protected Nd" badge where a floor applies,
"select all/none", and a confirm panel that names exactly which hosts
will be affected and which were skipped and why.
Verified live against real ClickHouse/Postgres and in-browser: three
hosts seeded, one protected by a 90-day floor -- a scoped delete
correctly removed the two open hosts' records, left the protected
host's untouched, and the response/UI both named it as skipped. Also
fixed a real spacing bug in the result message caught during that
browser pass (an adjacent {expr}{#if} with no source whitespace
between them rendered with no space either).
This commit is contained in:
+193
-66
@@ -3,7 +3,6 @@ package logretention
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -16,15 +15,16 @@ import (
|
||||
// is the production implementation; tests use a fake, same pattern as
|
||||
// agents.store/dashboards.store.
|
||||
type store interface {
|
||||
CountOlderThan(ctx context.Context, cutoff time.Time) (uint64, error)
|
||||
DeleteOlderThan(ctx context.Context, cutoff time.Time) error
|
||||
HostsOlderThan(ctx context.Context, cutoff time.Time) ([]HostCount, error)
|
||||
CountOlderThan(ctx context.Context, cutoff time.Time, hosts []string) (uint64, error)
|
||||
DeleteOlderThan(ctx context.Context, cutoff time.Time, hosts []string) error
|
||||
}
|
||||
|
||||
// retentionFloor is the narrow interface backing the owner-only
|
||||
// override check -- *AgentRetentionStore (agent_floor.go) is the
|
||||
// production implementation.
|
||||
type retentionFloor interface {
|
||||
MaxRetentionDays(ctx context.Context) (int, bool, error)
|
||||
RetentionDaysByHost(ctx context.Context) (map[string]int, error)
|
||||
}
|
||||
|
||||
// maxOlderThanHours bounds the age a caller can specify -- 10 years is
|
||||
@@ -33,6 +33,12 @@ type retentionFloor interface {
|
||||
// digit) with a clear 400 rather than silently accepting it.
|
||||
const maxOlderThanHours = 10 * 365 * 24
|
||||
|
||||
// maxHosts caps how many host filters one request can carry -- 1000 is
|
||||
// far beyond any real fleet this deployment's homelab/small-scale
|
||||
// target implies, and exists only to reject a pathological/malformed
|
||||
// request rather than to meaningfully restrict real usage.
|
||||
const maxHosts = 1000
|
||||
|
||||
type Handler struct {
|
||||
logger *slog.Logger
|
||||
store store
|
||||
@@ -44,21 +50,23 @@ func NewHandler(logger *slog.Logger, store store, floor retentionFloor, authoriz
|
||||
return &Handler{logger: logger, store: store, floor: floor, authorizer: authorizer}
|
||||
}
|
||||
|
||||
// RegisterRoutes: both routes are RoleAdmin -- RoleOwner satisfies it
|
||||
// too (Role.Satisfies is a floor, not an exact match), matching the
|
||||
// RegisterRoutes: all three routes are RoleAdmin -- RoleOwner satisfies
|
||||
// it too (Role.Satisfies is a floor, not an exact match), matching the
|
||||
// "owner and admin" requirement this feature shipped for. Permanently
|
||||
// deleting log data is at least as consequential as the RBAC matrix's
|
||||
// other RoleAdmin-floor actions (e.g. issuing an agent restart
|
||||
// command, api/agents/handler.go), so it gets the same floor rather
|
||||
// than a stricter RoleOwner-only one.
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /logs/retention/hosts", authz.RequireRole(h.authorizer, authz.RoleAdmin, h.handleHosts))
|
||||
mux.HandleFunc("GET /logs/retention/preview", authz.RequireRole(h.authorizer, authz.RoleAdmin, h.handlePreview))
|
||||
mux.HandleFunc("DELETE /logs/retention", authz.RequireRole(h.authorizer, authz.RoleAdmin, h.handleDelete))
|
||||
}
|
||||
|
||||
// parseOlderThanHours reads and validates the older_than_hours query
|
||||
// param shared by both routes -- a caller must ask for at least 1 hour
|
||||
// (an accidental empty/zero value must never mean "delete everything").
|
||||
// param shared by all three routes -- a caller must ask for at least 1
|
||||
// hour (an accidental empty/zero value must never mean "delete
|
||||
// everything").
|
||||
func parseOlderThanHours(r *http.Request) (int, bool) {
|
||||
hours, err := strconv.Atoi(r.URL.Query().Get("older_than_hours"))
|
||||
if err != nil || hours < 1 || hours > maxOlderThanHours {
|
||||
@@ -67,40 +75,145 @@ func parseOlderThanHours(r *http.Request) (int, bool) {
|
||||
return hours, true
|
||||
}
|
||||
|
||||
// checkRetentionFloor enforces api/agents.ConfigOverride.LogRetentionDays
|
||||
// as a hard floor against anyone but an owner: if any agent has a
|
||||
// configured retention, the largest one across all agents is the
|
||||
// earliest boundary a non-owner may delete up to. An owner always
|
||||
// bypasses this (identity.Role == RoleOwner short-circuits before ever
|
||||
// querying the floor) -- "owner and admin" gates the routes themselves
|
||||
// (RegisterRoutes), but this narrows what admin specifically can do
|
||||
// once inside them, the same shape handleSetConfig's own
|
||||
// log_retention_days gate uses on the agents side. A nil identity (no
|
||||
// authorizer configured at all, Phase 0-3 default-open) skips this
|
||||
// too, consistent with every other RBAC check in this codebase being a
|
||||
// no-op when there's no RBAC to begin with.
|
||||
func (h *Handler) checkRetentionFloor(ctx context.Context, cutoff time.Time) (string, error) {
|
||||
identity, ok := authz.IdentityFromContext(ctx)
|
||||
if !ok || identity.Role == authz.RoleOwner {
|
||||
return "", nil
|
||||
// parseHosts reads the repeated host query param shared by preview and
|
||||
// delete -- deliberately required (at least one), never "omitted means
|
||||
// every host": the whole point of this parameter existing is letting a
|
||||
// caller target specific agents' logs instead of wholesale deleting
|
||||
// everything, so there is no implicit "all hosts" shortcut here. GET
|
||||
// /logs/retention/hosts is how a caller discovers what to pass.
|
||||
// Duplicates are silently deduped; an empty host value is rejected
|
||||
// outright rather than silently dropped, since a caller sending "" almost
|
||||
// certainly has a client-side bug worth surfacing.
|
||||
func parseHosts(r *http.Request) ([]string, bool) {
|
||||
raw := r.URL.Query()["host"]
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
var hosts []string
|
||||
for _, host := range raw {
|
||||
if host == "" {
|
||||
return nil, false
|
||||
}
|
||||
if _, dup := seen[host]; dup {
|
||||
continue
|
||||
}
|
||||
seen[host] = struct{}{}
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
maxDays, hasFloor, err := h.floor.MaxRetentionDays(ctx)
|
||||
if len(hosts) == 0 || len(hosts) > maxHosts {
|
||||
return nil, false
|
||||
}
|
||||
return hosts, true
|
||||
}
|
||||
|
||||
// blockedHost is one entry in a preview/delete response's blocked_hosts
|
||||
// list -- a host the caller asked about that a configured retention
|
||||
// floor (api/agents.ConfigOverride.LogRetentionDays) protects from
|
||||
// anyone but an owner.
|
||||
type blockedHost struct {
|
||||
Host string `json:"host"`
|
||||
ProtectedDays int `json:"protected_days"`
|
||||
}
|
||||
|
||||
// partitionHosts splits hosts into allowed (this caller may act on
|
||||
// them) and blocked (a configured floor protects them from anyone but
|
||||
// an owner, and this caller isn't one) -- role-scoped rather than a
|
||||
// single all-or-nothing check, so a floor on one host never blocks
|
||||
// acting on other hosts requested in the same call. An owner always
|
||||
// gets everything back as allowed, no query needed.
|
||||
func (h *Handler) partitionHosts(ctx context.Context, role authz.Role, hosts []string, cutoff time.Time) ([]string, []blockedHost, error) {
|
||||
if role == authz.RoleOwner {
|
||||
return hosts, nil, nil
|
||||
}
|
||||
floors, err := h.floor.RetentionDaysByHost(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, nil, err
|
||||
}
|
||||
if !hasFloor {
|
||||
return "", nil
|
||||
now := time.Now().UTC()
|
||||
var allowed []string
|
||||
var blocked []blockedHost
|
||||
for _, host := range hosts {
|
||||
days, hasFloor := floors[host]
|
||||
if !hasFloor {
|
||||
allowed = append(allowed, host)
|
||||
continue
|
||||
}
|
||||
protectedBoundary := now.Add(-time.Duration(days) * 24 * time.Hour)
|
||||
if cutoff.After(protectedBoundary) {
|
||||
blocked = append(blocked, blockedHost{Host: host, ProtectedDays: days})
|
||||
} else {
|
||||
allowed = append(allowed, host)
|
||||
}
|
||||
}
|
||||
protectedBoundary := time.Now().UTC().Add(-time.Duration(maxDays) * 24 * time.Hour)
|
||||
if cutoff.After(protectedBoundary) {
|
||||
return fmt.Sprintf("a configured log retention policy protects logs newer than %d days; only an owner can override this", maxDays), nil
|
||||
return allowed, blocked, nil
|
||||
}
|
||||
|
||||
// roleForFloorCheck returns the identity's role, or RoleOwner (i.e.
|
||||
// "bypass the floor entirely") when no identity resolved at all -- a
|
||||
// nil authorizer means no RBAC is configured (Phase 0-3 default-open),
|
||||
// and this feature's owner-only override must stay a no-op in that
|
||||
// case too, consistent with every other RBAC check in this codebase.
|
||||
func roleForFloorCheck(ctx context.Context) authz.Role {
|
||||
identity, ok := authz.IdentityFromContext(ctx)
|
||||
if !ok {
|
||||
return authz.RoleOwner
|
||||
}
|
||||
return "", nil
|
||||
return identity.Role
|
||||
}
|
||||
|
||||
type hostEntry struct {
|
||||
Host string `json:"host"`
|
||||
Count uint64 `json:"count"`
|
||||
ProtectedDays *int `json:"protected_days,omitempty"`
|
||||
}
|
||||
|
||||
type hostsResponse struct {
|
||||
Hosts []hostEntry `json:"hosts"`
|
||||
Cutoff time.Time `json:"cutoff"`
|
||||
}
|
||||
|
||||
// handleHosts lists every host with at least one log record older than
|
||||
// the requested age, annotated with any configured retention floor --
|
||||
// informational for every caller regardless of role (RegisterRoutes'
|
||||
// RoleAdmin floor already gates who can reach this at all); it's what
|
||||
// populates the host picker a caller then selects from for preview/
|
||||
// delete, not itself an action that needs partitionHosts.
|
||||
func (h *Handler) handleHosts(w http.ResponseWriter, r *http.Request) {
|
||||
hours, ok := parseOlderThanHours(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "older_than_hours must be a positive integer")
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||
|
||||
counts, err := h.store.HostsOlderThan(r.Context(), cutoff)
|
||||
if err != nil {
|
||||
h.logger.Error("listing hosts for retention preview", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "listing hosts failed")
|
||||
return
|
||||
}
|
||||
floors, err := h.floor.RetentionDaysByHost(r.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("reading log retention floors", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "listing hosts failed")
|
||||
return
|
||||
}
|
||||
|
||||
entries := make([]hostEntry, len(counts))
|
||||
for i, c := range counts {
|
||||
e := hostEntry{Host: c.Host, Count: c.Count}
|
||||
if days, hasFloor := floors[c.Host]; hasFloor {
|
||||
d := days
|
||||
e.ProtectedDays = &d
|
||||
}
|
||||
entries[i] = e
|
||||
}
|
||||
writeJSON(w, http.StatusOK, hostsResponse{Hosts: entries, Cutoff: cutoff})
|
||||
}
|
||||
|
||||
type previewResponse struct {
|
||||
Count uint64 `json:"count"`
|
||||
Cutoff time.Time `json:"cutoff"`
|
||||
Count uint64 `json:"count"`
|
||||
Cutoff time.Time `json:"cutoff"`
|
||||
Hosts []string `json:"hosts"`
|
||||
BlockedHosts []blockedHost `json:"blocked_hosts,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) handlePreview(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -109,29 +222,37 @@ func (h *Handler) handlePreview(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "older_than_hours must be a positive integer")
|
||||
return
|
||||
}
|
||||
hosts, ok := parseHosts(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "at least one host must be specified")
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||
|
||||
if msg, err := h.checkRetentionFloor(r.Context(), cutoff); err != nil {
|
||||
allowed, blocked, err := h.partitionHosts(r.Context(), roleForFloorCheck(r.Context()), hosts, cutoff)
|
||||
if err != nil {
|
||||
h.logger.Error("checking log retention floor", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "checking retention policy failed")
|
||||
return
|
||||
} else if msg != "" {
|
||||
writeError(w, http.StatusForbidden, msg)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := h.store.CountOlderThan(r.Context(), cutoff)
|
||||
if err != nil {
|
||||
h.logger.Error("counting logs for retention preview", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "counting logs failed")
|
||||
return
|
||||
var count uint64
|
||||
if len(allowed) > 0 {
|
||||
count, err = h.store.CountOlderThan(r.Context(), cutoff, allowed)
|
||||
if err != nil {
|
||||
h.logger.Error("counting logs for retention preview", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "counting logs failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, previewResponse{Count: count, Cutoff: cutoff})
|
||||
writeJSON(w, http.StatusOK, previewResponse{Count: count, Cutoff: cutoff, Hosts: allowed, BlockedHosts: blocked})
|
||||
}
|
||||
|
||||
type deleteResponse struct {
|
||||
DeletedCount uint64 `json:"deleted_count"`
|
||||
Cutoff time.Time `json:"cutoff"`
|
||||
DeletedCount uint64 `json:"deleted_count"`
|
||||
Cutoff time.Time `json:"cutoff"`
|
||||
DeletedHosts []string `json:"deleted_hosts"`
|
||||
BlockedHosts []blockedHost `json:"blocked_hosts,omitempty"`
|
||||
}
|
||||
|
||||
// handleDelete counts immediately before deleting so the response can
|
||||
@@ -148,35 +269,41 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "older_than_hours must be a positive integer")
|
||||
return
|
||||
}
|
||||
hosts, ok := parseHosts(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "at least one host must be specified")
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||
|
||||
if msg, err := h.checkRetentionFloor(r.Context(), cutoff); err != nil {
|
||||
allowed, blocked, err := h.partitionHosts(r.Context(), roleForFloorCheck(r.Context()), hosts, cutoff)
|
||||
if err != nil {
|
||||
h.logger.Error("checking log retention floor", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "checking retention policy failed")
|
||||
return
|
||||
} else if msg != "" {
|
||||
writeError(w, http.StatusForbidden, msg)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := h.store.CountOlderThan(r.Context(), cutoff)
|
||||
if err != nil {
|
||||
h.logger.Error("counting logs before retention delete", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "counting logs failed")
|
||||
return
|
||||
var count uint64
|
||||
if len(allowed) > 0 {
|
||||
count, err = h.store.CountOlderThan(r.Context(), cutoff, allowed)
|
||||
if err != nil {
|
||||
h.logger.Error("counting logs before retention delete", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "counting logs failed")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteOlderThan(r.Context(), cutoff, allowed); err != nil {
|
||||
h.logger.Error("deleting logs by retention age", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "deleting logs failed")
|
||||
return
|
||||
}
|
||||
|
||||
identity, _ := authz.IdentityFromContext(r.Context())
|
||||
h.logger.Info("logs deleted by retention age",
|
||||
"deleted_count", count, "cutoff", cutoff, "hosts", allowed, "user_id", identity.UserID, "role", identity.Role)
|
||||
}
|
||||
|
||||
if err := h.store.DeleteOlderThan(r.Context(), cutoff); err != nil {
|
||||
h.logger.Error("deleting logs by retention age", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "deleting logs failed")
|
||||
return
|
||||
}
|
||||
|
||||
identity, _ := authz.IdentityFromContext(r.Context())
|
||||
h.logger.Info("logs deleted by retention age",
|
||||
"deleted_count", count, "cutoff", cutoff, "user_id", identity.UserID, "role", identity.Role)
|
||||
|
||||
writeJSON(w, http.StatusOK, deleteResponse{DeletedCount: count, Cutoff: cutoff})
|
||||
writeJSON(w, http.StatusOK, deleteResponse{DeletedCount: count, Cutoff: cutoff, DeletedHosts: allowed, BlockedHosts: blocked})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
|
||||
Reference in New Issue
Block a user