Add per-agent log retention floor, owner-only to set or override
api/agents.ConfigOverride gains LogRetentionDays: a per-agent setting edited on the same remote-config page as extra_file_paths, but unlike every other field there it's central-policy metadata api/logretention reads, never something the agent process itself sees. Any change to it -- setting, raising, lowering, or clearing -- requires RoleOwner, not just RoleAdmin: the whole point of the field is a floor an admin can't move, so an admin able to freely edit it would defeat that. api/logretention now checks the largest LogRetentionDays configured across any agent (AgentRetentionStore, new) before every preview/delete: a non-owner's request is rejected with a clear 403 if it would reach into that protected window. An owner always bypasses it, matching "make the log retention override any attempts to delete logs by anyone other than owner role." Verified live end-to-end: owner sets a 90-day floor on an agent, admin is blocked deleting anything newer than that (both preview and delete), allowed beyond it, and owner bypasses it entirely -- confirmed against real ClickHouse data, not just the fake-backed unit tests. Also caught and fixed a real pre-existing latent bug while verifying in-browser: a type="number" Input's bind:value becomes an actual JS number once a user types into it (only the initial value is a string), which broke a bare .trim() call on the new field.
This commit is contained in:
@@ -3,6 +3,7 @@ package logretention
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -19,6 +20,13 @@ type store interface {
|
||||
DeleteOlderThan(ctx context.Context, cutoff time.Time) 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)
|
||||
}
|
||||
|
||||
// maxOlderThanHours bounds the age a caller can specify -- 10 years is
|
||||
// far beyond any real retention window this feature exists for, and
|
||||
// exists only to reject an obviously-wrong input (e.g. a stray extra
|
||||
@@ -28,11 +36,12 @@ const maxOlderThanHours = 10 * 365 * 24
|
||||
type Handler struct {
|
||||
logger *slog.Logger
|
||||
store store
|
||||
floor retentionFloor
|
||||
authorizer authz.Authorizer
|
||||
}
|
||||
|
||||
func NewHandler(logger *slog.Logger, store store, authorizer authz.Authorizer) *Handler {
|
||||
return &Handler{logger: logger, store: store, authorizer: authorizer}
|
||||
func NewHandler(logger *slog.Logger, store store, floor retentionFloor, authorizer authz.Authorizer) *Handler {
|
||||
return &Handler{logger: logger, store: store, floor: floor, authorizer: authorizer}
|
||||
}
|
||||
|
||||
// RegisterRoutes: both routes are RoleAdmin -- RoleOwner satisfies it
|
||||
@@ -58,6 +67,37 @@ 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
|
||||
}
|
||||
maxDays, hasFloor, err := h.floor.MaxRetentionDays(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !hasFloor {
|
||||
return "", nil
|
||||
}
|
||||
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 "", nil
|
||||
}
|
||||
|
||||
type previewResponse struct {
|
||||
Count uint64 `json:"count"`
|
||||
Cutoff time.Time `json:"cutoff"`
|
||||
@@ -71,6 +111,15 @@ func (h *Handler) handlePreview(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||
|
||||
if msg, err := h.checkRetentionFloor(r.Context(), cutoff); 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)
|
||||
@@ -101,6 +150,15 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||
|
||||
if msg, err := h.checkRetentionFloor(r.Context(), cutoff); 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)
|
||||
|
||||
Reference in New Issue
Block a user