Scope log retention deletion and floors to (host, service), not host alone

logs rows carry a real per-record `service` (nginx, smtp, ufw, ...) --
already true of the schema (storage/migrations/0001) and wire protocol,
not something this feature invents. Both the deletion picker and the
retention floor now operate on (host, service) pairs instead of whole
hosts, so an operator can delete just one noisy log type from an agent
without touching everything else it ships, and can protect one service
(e.g. keep smtp a year) longer than the rest of that host's default.

api/agents.ConfigOverride gains ServiceLogRetentionDays (map[string]int),
owner-only to change like LogRetentionDays -- a service listed there
overrides the host's LogRetentionDays default for that service only.
Agent config page gets a matching "Per-service log retention overrides"
add/remove list next to the existing host-level field.

api/logretention: Store's count/delete now take []HostService and build
a ClickHouse tuple IN ((?,?),...) over (host, service); AgentRetentionStore.
FloorsByHost returns each host's default plus its per-service map, with
HostFloor.Effective(service) resolving which one applies. preview/delete
moved from GET/DELETE-with-query-params to POST-with-JSON-body (a list of
targets needs a real body, not a repeated compound query param), and
partitionTargets checks the floor per target so one protected service
never blocks deleting a different, unprotected one in the same request.

Settings' Log retention section is a two-level picker now: each host
row (with a "select all services" checkbox and its default floor badge)
expands to its services, each with its own count and effective
protected-days badge.

Verified live against real ClickHouse/Postgres and in-browser: a host
with a 7-day default plus a 365-day smtp override -- deleting nginx+
smtp+ufw together correctly removed nginx and ufw, left smtp's 10
records untouched, and confirmed via a follow-up owner delete that
bypassing the floor works. Also verified the full click-through (add a
service override on the agent page, see it reflected in Settings'
picker, select/preview/cancel) and confirmed no regression from the
prior host-only version's tests.
This commit is contained in:
2026-08-21 15:54:58 -07:00
parent 087c52a64f
commit 5ff2e5bb60
10 changed files with 878 additions and 410 deletions
+249 -163
View File
@@ -1,6 +1,7 @@
package logretention
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -9,7 +10,6 @@ import (
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"testing"
"time"
@@ -20,43 +20,43 @@ func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// hostCall records one CountOlderThan/DeleteOlderThan invocation, so
// tests can assert both the cutoff and the exact host set a call used.
type hostCall struct {
cutoff time.Time
hosts []string
// targetCall records one CountOlderThan/DeleteOlderThan invocation, so
// tests can assert both the cutoff and the exact target set a call used.
type targetCall struct {
cutoff time.Time
targets []HostService
}
// fakeStore lets a test inject store errors and a fixed host listing,
// fakeStore lets a test inject store errors and a fixed target listing,
// and records every count/delete call it received so tests can assert
// the handler scoped them to the right hosts.
// the handler scoped them to the right targets.
type fakeStore struct {
hostList []HostCount
hostsErr error
targetList []TargetCount
targetsErr error
count uint64
countErr error
deleteErr error
countedWith []hostCall
deletedWith []hostCall
countedWith []targetCall
deletedWith []targetCall
}
func (f *fakeStore) HostsOlderThan(_ context.Context, _ time.Time) ([]HostCount, error) {
if f.hostsErr != nil {
return nil, f.hostsErr
func (f *fakeStore) TargetsOlderThan(_ context.Context, _ time.Time) ([]TargetCount, error) {
if f.targetsErr != nil {
return nil, f.targetsErr
}
return f.hostList, nil
return f.targetList, nil
}
func (f *fakeStore) CountOlderThan(_ context.Context, cutoff time.Time, hosts []string) (uint64, error) {
f.countedWith = append(f.countedWith, hostCall{cutoff, hosts})
func (f *fakeStore) CountOlderThan(_ context.Context, cutoff time.Time, targets []HostService) (uint64, error) {
f.countedWith = append(f.countedWith, targetCall{cutoff, targets})
if f.countErr != nil {
return 0, f.countErr
}
return f.count, nil
}
func (f *fakeStore) DeleteOlderThan(_ context.Context, cutoff time.Time, hosts []string) error {
f.deletedWith = append(f.deletedWith, hostCall{cutoff, hosts})
func (f *fakeStore) DeleteOlderThan(_ context.Context, cutoff time.Time, targets []HostService) error {
f.deletedWith = append(f.deletedWith, targetCall{cutoff, targets})
return f.deleteErr
}
@@ -69,14 +69,14 @@ func (f fakeAuthorizer) Authorize(*http.Request) (authz.Identity, error) {
}
// fakeFloor stands in for AgentRetentionStore -- a nil/empty byHost map
// means no agent has log_retention_days configured, same as every test
// that doesn't care about the floor assumed before it existed.
// means no agent has any retention floor configured, same as every
// test that doesn't care about the floor assumed before it existed.
type fakeFloor struct {
byHost map[string]int
byHost map[string]HostFloor
err error
}
func (f fakeFloor) RetentionDaysByHost(context.Context) (map[string]int, error) {
func (f fakeFloor) FloorsByHost(context.Context) (map[string]HostFloor, error) {
return f.byHost, f.err
}
@@ -94,13 +94,35 @@ func doRequest(t *testing.T, h *Handler, method, path string) *httptest.Response
return rec
}
func hoursForDays(days int) string {
return strconv.Itoa(days * 24)
func doJSONRequest(t *testing.T, h *Handler, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshaling request body: %v", err)
}
req := httptest.NewRequest(method, path, bytes.NewReader(b))
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
return rec
}
func TestHostsListsHostsWithCountsAndFloors(t *testing.T) {
s := &fakeStore{hostList: []HostCount{{Host: "web-01", Count: 100}, {Host: "web-02", Count: 5}}}
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]int{"web-02": 90}}, fakeAuthorizer{role: authz.RoleAdmin})
func hoursForDays(days int) int {
return days * 24
}
func intPtr(n int) *int { return &n }
func TestHostsListsTargetsGroupedByHostWithFloors(t *testing.T) {
s := &fakeStore{targetList: []TargetCount{
{Host: "web-01", Service: "nginx", Count: 100},
{Host: "web-01", Service: "smtp", Count: 5},
{Host: "web-02", Service: "ufw", Count: 20},
}}
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]HostFloor{
"web-01": {DefaultDays: intPtr(7), ServiceDays: map[string]int{"smtp": 365}},
}}, fakeAuthorizer{role: authz.RoleAdmin})
rec := doRequest(t, h, "GET", "/logs/retention/hosts?older_than_hours=24")
if rec.Code != http.StatusOK {
@@ -113,20 +135,39 @@ func TestHostsListsHostsWithCountsAndFloors(t *testing.T) {
if len(resp.Hosts) != 2 {
t.Fatalf("len(hosts) = %d, want 2", len(resp.Hosts))
}
if resp.Hosts[0].Host != "web-01" || resp.Hosts[0].Count != 100 || resp.Hosts[0].ProtectedDays != nil {
t.Errorf("hosts[0] = %+v, want web-01/100/no floor", resp.Hosts[0])
web01 := resp.Hosts[0]
if web01.Host != "web-01" || web01.ProtectedDays == nil || *web01.ProtectedDays != 7 {
t.Fatalf("hosts[0] = %+v, want web-01 with host default floor 7", web01)
}
if resp.Hosts[1].Host != "web-02" || resp.Hosts[1].Count != 5 || resp.Hosts[1].ProtectedDays == nil || *resp.Hosts[1].ProtectedDays != 90 {
t.Errorf("hosts[1] = %+v, want web-02/5/floor=90", resp.Hosts[1])
if len(web01.Services) != 2 {
t.Fatalf("web-01 services = %+v, want 2 entries", web01.Services)
}
if web01.Services[0].Service != "nginx" || web01.Services[0].Count != 100 || web01.Services[0].ProtectedDays == nil || *web01.Services[0].ProtectedDays != 7 {
t.Errorf("web-01/nginx = %+v, want count=100 protected_days=7 (host default)", web01.Services[0])
}
if web01.Services[1].Service != "smtp" || web01.Services[1].ProtectedDays == nil || *web01.Services[1].ProtectedDays != 365 {
t.Errorf("web-01/smtp = %+v, want protected_days=365 (service override, not the host default)", web01.Services[1])
}
web02 := resp.Hosts[1]
if web02.Host != "web-02" || web02.ProtectedDays != nil {
t.Fatalf("hosts[1] = %+v, want web-02 with no floor", web02)
}
if len(web02.Services) != 1 || web02.Services[0].ProtectedDays != nil {
t.Errorf("web-02 services = %+v, want ufw with no floor", web02.Services)
}
}
func TestPreviewReturnsCountCutoffAndHosts(t *testing.T) {
func TestPreviewReturnsCountCutoffAndTargets(t *testing.T) {
s := &fakeStore{count: 42}
h := newTestHandler(s, authz.RoleAdmin)
before := time.Now().UTC()
rec := doRequest(t, h, "GET", "/logs/retention/preview?older_than_hours=24&host=web-01&host=web-02")
rec := doJSONRequest(t, h, "POST", "/logs/retention/preview", deletionRequest{
OlderThanHours: 24,
Targets: []HostService{{Host: "web-01", Service: "nginx"}, {Host: "web-01", Service: "smtp"}},
})
after := time.Now().UTC()
if rec.Code != http.StatusOK {
@@ -139,8 +180,9 @@ func TestPreviewReturnsCountCutoffAndHosts(t *testing.T) {
if resp.Count != 42 {
t.Errorf("count = %d, want 42", resp.Count)
}
if !reflect.DeepEqual(resp.Hosts, []string{"web-01", "web-02"}) {
t.Errorf("hosts = %v, want [web-01 web-02]", resp.Hosts)
want := []HostService{{Host: "web-01", Service: "nginx"}, {Host: "web-01", Service: "smtp"}}
if !reflect.DeepEqual(resp.Targets, want) {
t.Errorf("targets = %v, want %v", resp.Targets, want)
}
wantEarliest := before.Add(-24 * time.Hour)
wantLatest := after.Add(-24 * time.Hour)
@@ -150,47 +192,76 @@ func TestPreviewReturnsCountCutoffAndHosts(t *testing.T) {
if len(s.deletedWith) != 0 {
t.Errorf("preview must never delete anything, but DeleteOlderThan was called %d time(s)", len(s.deletedWith))
}
if len(s.countedWith) != 1 || !reflect.DeepEqual(s.countedWith[0].hosts, []string{"web-01", "web-02"}) {
t.Errorf("CountOlderThan was not scoped to the requested hosts: %+v", s.countedWith)
if len(s.countedWith) != 1 || !reflect.DeepEqual(s.countedWith[0].targets, want) {
t.Errorf("CountOlderThan was not scoped to the requested targets: %+v", s.countedWith)
}
}
func TestPreviewDedupesHosts(t *testing.T) {
func TestPreviewDedupesTargets(t *testing.T) {
s := &fakeStore{count: 1}
h := newTestHandler(s, authz.RoleAdmin)
rec := doRequest(t, h, "GET", "/logs/retention/preview?older_than_hours=24&host=web-01&host=web-01")
rec := doJSONRequest(t, h, "POST", "/logs/retention/preview", deletionRequest{
OlderThanHours: 24,
Targets: []HostService{
{Host: "web-01", Service: "nginx"},
{Host: "web-01", Service: "nginx"},
},
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
if len(s.countedWith) != 1 || !reflect.DeepEqual(s.countedWith[0].hosts, []string{"web-01"}) {
t.Fatalf("expected a deduped single-host call, got %+v", s.countedWith)
want := []HostService{{Host: "web-01", Service: "nginx"}}
if len(s.countedWith) != 1 || !reflect.DeepEqual(s.countedWith[0].targets, want) {
t.Fatalf("expected a deduped single-target call, got %+v", s.countedWith)
}
}
func TestPreviewRequiresAtLeastOneHost(t *testing.T) {
func TestPreviewRequiresAtLeastOneTarget(t *testing.T) {
h := newTestHandler(&fakeStore{}, authz.RoleAdmin)
rec := doRequest(t, h, "GET", "/logs/retention/preview?older_than_hours=24")
rec := doJSONRequest(t, h, "POST", "/logs/retention/preview", deletionRequest{OlderThanHours: 24})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 with no host specified", rec.Code)
t.Fatalf("status = %d, want 400 with no targets specified", rec.Code)
}
}
func TestPreviewRejectsEmptyHostValue(t *testing.T) {
func TestPreviewRejectsTargetWithEmptyHostOrService(t *testing.T) {
h := newTestHandler(&fakeStore{}, authz.RoleAdmin)
rec := doRequest(t, h, "GET", "/logs/retention/preview?older_than_hours=24&host=")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 with an empty host value", rec.Code)
cases := [][]HostService{
{{Host: "", Service: "nginx"}},
{{Host: "web-01", Service: ""}},
}
for _, targets := range cases {
rec := doJSONRequest(t, h, "POST", "/logs/retention/preview", deletionRequest{OlderThanHours: 24, Targets: targets})
if rec.Code != http.StatusBadRequest {
t.Errorf("targets %v: status = %d, want 400", targets, rec.Code)
}
}
}
func TestDeleteReturnsDeletedCountHostsAndCutoff(t *testing.T) {
func TestPreviewRejectsInvalidOlderThanHours(t *testing.T) {
h := newTestHandler(&fakeStore{}, authz.RoleAdmin)
targets := []HostService{{Host: "web-01", Service: "nginx"}}
cases := []int{0, -5, 999999999}
for _, hours := range cases {
rec := doJSONRequest(t, h, "POST", "/logs/retention/preview", deletionRequest{OlderThanHours: hours, Targets: targets})
if rec.Code != http.StatusBadRequest {
t.Errorf("older_than_hours=%d: status = %d, want 400", hours, rec.Code)
}
}
}
func TestDeleteReturnsDeletedCountTargetsAndCutoff(t *testing.T) {
s := &fakeStore{count: 7}
h := newTestHandler(s, authz.RoleAdmin)
rec := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours=720&host=web-01")
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{
OlderThanHours: 720,
Targets: []HostService{{Host: "web-01", Service: "nginx"}},
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
@@ -201,10 +272,11 @@ func TestDeleteReturnsDeletedCountHostsAndCutoff(t *testing.T) {
if resp.DeletedCount != 7 {
t.Errorf("deleted_count = %d, want 7", resp.DeletedCount)
}
if !reflect.DeepEqual(resp.DeletedHosts, []string{"web-01"}) {
t.Errorf("deleted_hosts = %v, want [web-01]", resp.DeletedHosts)
want := []HostService{{Host: "web-01", Service: "nginx"}}
if !reflect.DeepEqual(resp.DeletedTargets, want) {
t.Errorf("deleted_targets = %v, want %v", resp.DeletedTargets, want)
}
if len(s.deletedWith) != 1 || !reflect.DeepEqual(s.deletedWith[0].hosts, []string{"web-01"}) {
if len(s.deletedWith) != 1 || !reflect.DeepEqual(s.deletedWith[0].targets, want) {
t.Fatalf("expected exactly one scoped DeleteOlderThan call, got %+v", s.deletedWith)
}
if len(s.countedWith) != 1 || !s.countedWith[0].cutoff.Equal(s.deletedWith[0].cutoff) {
@@ -212,61 +284,47 @@ func TestDeleteReturnsDeletedCountHostsAndCutoff(t *testing.T) {
}
}
func TestRejectsMissingOrInvalidOlderThanHours(t *testing.T) {
func TestDeleteRejectsMissingTargets(t *testing.T) {
s := &fakeStore{}
h := newTestHandler(s, authz.RoleAdmin)
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{OlderThanHours: 24})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 with no targets specified", rec.Code)
}
if len(s.deletedWith) != 0 {
t.Error("a request with no targets specified must never reach the store's delete path")
}
}
func TestDeleteRejectsInvalidJSONBody(t *testing.T) {
h := newTestHandler(&fakeStore{}, authz.RoleAdmin)
cases := []string{
"/logs/retention/preview?host=web-01",
"/logs/retention/preview?older_than_hours=0&host=web-01",
"/logs/retention/preview?older_than_hours=-5&host=web-01",
"/logs/retention/preview?older_than_hours=notanumber&host=web-01",
"/logs/retention/preview?older_than_hours=999999999&host=web-01",
}
for _, path := range cases {
rec := doRequest(t, h, "GET", path)
if rec.Code != http.StatusBadRequest {
t.Errorf("path %q: status = %d, want 400", path, rec.Code)
}
}
}
func TestDeleteRejectsInvalidOlderThanHours(t *testing.T) {
s := &fakeStore{}
h := newTestHandler(s, authz.RoleAdmin)
rec := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours=0&host=web-01")
req := httptest.NewRequest("POST", "/logs/retention/delete", bytes.NewReader([]byte("not json")))
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if len(s.deletedWith) != 0 {
t.Error("an invalid older_than_hours must never reach the store's delete path")
}
}
func TestDeleteRejectsMissingHosts(t *testing.T) {
s := &fakeStore{}
h := newTestHandler(s, authz.RoleAdmin)
rec := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours=24")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 with no host specified", rec.Code)
}
if len(s.deletedWith) != 0 {
t.Error("a request with no host specified must never reach the store's delete path")
}
}
func TestDeletePropagatesStoreErrors(t *testing.T) {
s := &fakeStore{deleteErr: errors.New("clickhouse mutation failed")}
h := newTestHandler(s, authz.RoleAdmin)
rec := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours=24&host=web-01")
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{
OlderThanHours: 24,
Targets: []HostService{{Host: "web-01", Service: "nginx"}},
})
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
}
func TestOwnerAndAdminCanUseRetentionRoutes(t *testing.T) {
targets := []HostService{{Host: "web-01", Service: "nginx"}}
for _, role := range []authz.Role{authz.RoleAdmin, authz.RoleOwner} {
s := &fakeStore{count: 3}
h := newTestHandler(s, role)
@@ -275,11 +333,11 @@ func TestOwnerAndAdminCanUseRetentionRoutes(t *testing.T) {
if hosts.Code != http.StatusOK {
t.Errorf("role %s: hosts status = %d, want 200", role, hosts.Code)
}
preview := doRequest(t, h, "GET", "/logs/retention/preview?older_than_hours=24&host=web-01")
preview := doJSONRequest(t, h, "POST", "/logs/retention/preview", deletionRequest{OlderThanHours: 24, Targets: targets})
if preview.Code != http.StatusOK {
t.Errorf("role %s: preview status = %d, want 200", role, preview.Code)
}
del := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours=24&host=web-01")
del := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{OlderThanHours: 24, Targets: targets})
if del.Code != http.StatusOK {
t.Errorf("role %s: delete status = %d, want 200", role, del.Code)
}
@@ -287,6 +345,7 @@ func TestOwnerAndAdminCanUseRetentionRoutes(t *testing.T) {
}
func TestViewerAndEditorAreForbiddenFromRetentionRoutes(t *testing.T) {
targets := []HostService{{Host: "web-01", Service: "nginx"}}
for _, role := range []authz.Role{authz.RoleViewer, authz.RoleEditor} {
s := &fakeStore{count: 3}
h := newTestHandler(s, role)
@@ -295,11 +354,11 @@ func TestViewerAndEditorAreForbiddenFromRetentionRoutes(t *testing.T) {
if hosts.Code != http.StatusForbidden {
t.Errorf("role %s: hosts status = %d, want 403", role, hosts.Code)
}
preview := doRequest(t, h, "GET", "/logs/retention/preview?older_than_hours=24&host=web-01")
preview := doJSONRequest(t, h, "POST", "/logs/retention/preview", deletionRequest{OlderThanHours: 24, Targets: targets})
if preview.Code != http.StatusForbidden {
t.Errorf("role %s: preview status = %d, want 403", role, preview.Code)
}
del := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours=24&host=web-01")
del := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{OlderThanHours: 24, Targets: targets})
if del.Code != http.StatusForbidden {
t.Errorf("role %s: delete status = %d, want 403", role, del.Code)
}
@@ -318,23 +377,31 @@ func TestRetentionRoutesRequireAuth(t *testing.T) {
// here too, same as every other RequireRole-wrapped route, rather
// than this package accidentally being open or closed by default in
// a way inconsistent with the rest of the API.
rec := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours=24&host=web-01")
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{
OlderThanHours: 24,
Targets: []HostService{{Host: "web-01", Service: "nginx"}},
})
if rec.Code != http.StatusOK {
t.Fatalf("status with nil authorizer = %d, want 200 (default-open, matches RequireRole elsewhere)", rec.Code)
}
}
// TestAdminPartiallyBlockedByPerHostRetentionFloor is the core
// regression test for host-scoped floor enforcement: requesting two
// hosts where only one has a protective floor must delete the
// unprotected host and report the other as blocked, not reject the
// TestAdminPartiallyBlockedByPerTargetRetentionFloor is the core
// regression test for target-scoped floor enforcement: requesting two
// targets where only one has a protective floor must delete the
// unprotected target and report the other as blocked, not reject the
// whole request.
func TestAdminPartiallyBlockedByPerHostRetentionFloor(t *testing.T) {
func TestAdminPartiallyBlockedByPerTargetRetentionFloor(t *testing.T) {
s := &fakeStore{count: 5}
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]int{"protected-host": 90}}, fakeAuthorizer{role: authz.RoleAdmin})
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]HostFloor{
"web-01": {ServiceDays: map[string]int{"smtp": 90}},
}}, fakeAuthorizer{role: authz.RoleAdmin})
// 30 days is newer than protected-host's 90-day floor.
rec := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours="+hoursForDays(30)+"&host=protected-host&host=open-host")
// 30 days is newer than smtp's 90-day floor.
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{
OlderThanHours: hoursForDays(30),
Targets: []HostService{{Host: "web-01", Service: "smtp"}, {Host: "web-01", Service: "nginx"}},
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (partial success, not an error), body=%s", rec.Code, rec.Body.String())
}
@@ -342,26 +409,60 @@ func TestAdminPartiallyBlockedByPerHostRetentionFloor(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if !reflect.DeepEqual(resp.DeletedHosts, []string{"open-host"}) {
t.Errorf("deleted_hosts = %v, want [open-host]", resp.DeletedHosts)
wantDeleted := []HostService{{Host: "web-01", Service: "nginx"}}
if !reflect.DeepEqual(resp.DeletedTargets, wantDeleted) {
t.Errorf("deleted_targets = %v, want %v", resp.DeletedTargets, wantDeleted)
}
if len(resp.BlockedHosts) != 1 || resp.BlockedHosts[0].Host != "protected-host" || resp.BlockedHosts[0].ProtectedDays != 90 {
t.Errorf("blocked_hosts = %+v, want [{protected-host 90}]", resp.BlockedHosts)
if len(resp.BlockedTargets) != 1 || resp.BlockedTargets[0] != (blockedTarget{Host: "web-01", Service: "smtp", ProtectedDays: 90}) {
t.Errorf("blocked_targets = %+v, want [{web-01 smtp 90}]", resp.BlockedTargets)
}
if len(s.deletedWith) != 1 || !reflect.DeepEqual(s.deletedWith[0].hosts, []string{"open-host"}) {
t.Fatalf("DeleteOlderThan must only ever be scoped to the allowed host, got %+v", s.deletedWith)
if len(s.deletedWith) != 1 || !reflect.DeepEqual(s.deletedWith[0].targets, wantDeleted) {
t.Fatalf("DeleteOlderThan must only ever be scoped to the allowed target, got %+v", s.deletedWith)
}
}
// TestAllHostsBlockedReturnsZeroCountNotError confirms a request where
// every requested host is protected still succeeds (200), just with
// nothing deleted -- informative, not an error condition, since the
// request itself was perfectly valid.
func TestAllHostsBlockedReturnsZeroCountNotError(t *testing.T) {
s := &fakeStore{count: 100}
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]int{"protected-host": 90}}, fakeAuthorizer{role: authz.RoleAdmin})
// TestServiceOverrideBeatsHostDefault confirms Effective's precedence:
// a service-specific floor applies over the host default even when the
// host default alone would have allowed the request.
func TestServiceOverrideBeatsHostDefault(t *testing.T) {
s := &fakeStore{count: 5}
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]HostFloor{
"web-01": {DefaultDays: intPtr(7), ServiceDays: map[string]int{"smtp": 365}},
}}, fakeAuthorizer{role: authz.RoleAdmin})
rec := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours="+hoursForDays(30)+"&host=protected-host")
// 30 days clears the 7-day host default but not smtp's 365-day
// override.
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{
OlderThanHours: hoursForDays(30),
Targets: []HostService{{Host: "web-01", Service: "smtp"}, {Host: "web-01", Service: "nginx"}},
})
var resp deleteResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
wantDeleted := []HostService{{Host: "web-01", Service: "nginx"}}
if !reflect.DeepEqual(resp.DeletedTargets, wantDeleted) {
t.Errorf("deleted_targets = %v, want %v (nginx uses the 7-day default, smtp its own 365-day override)", resp.DeletedTargets, wantDeleted)
}
if len(resp.BlockedTargets) != 1 || resp.BlockedTargets[0].ProtectedDays != 365 {
t.Errorf("blocked_targets = %+v, want smtp blocked at 365 days", resp.BlockedTargets)
}
}
// TestAllTargetsBlockedReturnsZeroCountNotError confirms a request
// where every requested target is protected still succeeds (200), just
// with nothing deleted -- informative, not an error condition, since
// the request itself was perfectly valid.
func TestAllTargetsBlockedReturnsZeroCountNotError(t *testing.T) {
s := &fakeStore{count: 100}
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]HostFloor{
"web-01": {ServiceDays: map[string]int{"smtp": 90}},
}}, fakeAuthorizer{role: authz.RoleAdmin})
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{
OlderThanHours: hoursForDays(30),
Targets: []HostService{{Host: "web-01", Service: "smtp"}},
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
@@ -369,38 +470,14 @@ func TestAllHostsBlockedReturnsZeroCountNotError(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.DeletedCount != 0 {
t.Errorf("deleted_count = %d, want 0", resp.DeletedCount)
if resp.DeletedCount != 0 || len(resp.DeletedTargets) != 0 {
t.Errorf("deleted_count/targets = %d/%v, want 0/empty", resp.DeletedCount, resp.DeletedTargets)
}
if len(resp.DeletedHosts) != 0 {
t.Errorf("deleted_hosts = %v, want empty", resp.DeletedHosts)
}
if len(resp.BlockedHosts) != 1 || resp.BlockedHosts[0].Host != "protected-host" {
t.Errorf("blocked_hosts = %+v, want [{protected-host 90}]", resp.BlockedHosts)
if len(resp.BlockedTargets) != 1 {
t.Errorf("blocked_targets = %+v, want one entry", resp.BlockedTargets)
}
if len(s.deletedWith) != 0 || len(s.countedWith) != 0 {
t.Error("the store must never be called when every requested host is blocked")
}
}
// TestAdminAllowedBeyondRetentionFloor confirms the floor only blocks
// requests that would actually reach into the protected window -- a
// request older than the floor itself is unaffected by it.
func TestAdminAllowedBeyondRetentionFloor(t *testing.T) {
s := &fakeStore{count: 5}
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]int{"web-01": 90}}, fakeAuthorizer{role: authz.RoleAdmin})
// 120 days is older than the 90-day floor -- must be allowed.
del := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours="+hoursForDays(120)+"&host=web-01")
if del.Code != http.StatusOK {
t.Fatalf("delete at 120d against a 90d floor: status = %d, want 200, body=%s", del.Code, del.Body.String())
}
var resp deleteResponse
if err := json.Unmarshal(del.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if len(resp.BlockedHosts) != 0 {
t.Errorf("blocked_hosts = %+v, want none", resp.BlockedHosts)
t.Error("the store must never be called when every requested target is blocked")
}
}
@@ -409,31 +486,37 @@ func TestAdminAllowedBeyondRetentionFloor(t *testing.T) {
// window that blocks everyone else.
func TestOwnerBypassesRetentionFloor(t *testing.T) {
s := &fakeStore{count: 100}
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]int{"web-01": 90}}, fakeAuthorizer{role: authz.RoleOwner})
h := NewHandler(discardLogger(), s, fakeFloor{byHost: map[string]HostFloor{
"web-01": {ServiceDays: map[string]int{"smtp": 90}},
}}, fakeAuthorizer{role: authz.RoleOwner})
del := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours="+hoursForDays(1)+"&host=web-01")
if del.Code != http.StatusOK {
t.Fatalf("owner deleting within the floor: status = %d, want 200, body=%s", del.Code, del.Body.String())
}
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{
OlderThanHours: hoursForDays(1),
Targets: []HostService{{Host: "web-01", Service: "smtp"}},
})
var resp deleteResponse
if err := json.Unmarshal(del.Body.Bytes(), &resp); err != nil {
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if !reflect.DeepEqual(resp.DeletedHosts, []string{"web-01"}) {
t.Errorf("deleted_hosts = %v, want [web-01] (owner bypasses the floor entirely)", resp.DeletedHosts)
want := []HostService{{Host: "web-01", Service: "smtp"}}
if !reflect.DeepEqual(resp.DeletedTargets, want) {
t.Errorf("deleted_targets = %v, want %v (owner bypasses the floor entirely)", resp.DeletedTargets, want)
}
}
// TestNoConfiguredFloorNeverBlocksAdmin confirms the default, common
// case (no agent has log_retention_days set) behaves exactly as before
// this feature existed.
// case (no agent has any retention floor set) behaves exactly as
// before this feature existed.
func TestNoConfiguredFloorNeverBlocksAdmin(t *testing.T) {
s := &fakeStore{count: 9}
h := NewHandler(discardLogger(), s, fakeFloor{}, fakeAuthorizer{role: authz.RoleAdmin})
del := doRequest(t, h, "DELETE", "/logs/retention?older_than_hours=1&host=web-01")
if del.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 with no configured floor", del.Code)
rec := doJSONRequest(t, h, "POST", "/logs/retention/delete", deletionRequest{
OlderThanHours: 1,
Targets: []HostService{{Host: "web-01", Service: "nginx"}},
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 with no configured floor", rec.Code)
}
}
@@ -441,7 +524,10 @@ func TestRetentionFloorCheckPropagatesStoreErrors(t *testing.T) {
s := &fakeStore{}
h := NewHandler(discardLogger(), s, fakeFloor{err: errors.New("postgres unreachable")}, fakeAuthorizer{role: authz.RoleAdmin})
rec := doRequest(t, h, "GET", "/logs/retention/preview?older_than_hours=24&host=web-01")
rec := doJSONRequest(t, h, "POST", "/logs/retention/preview", deletionRequest{
OlderThanHours: 24,
Targets: []HostService{{Host: "web-01", Service: "nginx"}},
})
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}