feat(rbac): daily retention sweep for audit_logs (default 90 days)

The audit_logs table grows monotonically until something purges it.
PR 6 explicitly listed retention as out of scope for v1; this picks
that up.

Adds an `audit.retention_days` config (default 90, 0 disables) and a
small background goroutine `AuditLogRetentionRunner` that fires once
~10 minutes after boot and then every 24h, calling
`AuditLogService.Purge` which DELETEs rows older than the cutoff in a
single statement. The dependency surface is intentionally minimal —
no robfig/cron, no asynq, no migration — because retention has no
wall-clock alignment requirement.

Wiring is the same shape as the existing data source scheduler:
container `Provide(NewAuditLogRetentionRunner)`, an `Invoke` that
calls `Start`, and a `ResourceCleaner.RegisterWithName` for graceful
shutdown.

Defaults preserve operator intent:
  - `audit:` section omitted from YAML  -> retention_days=90.
  - explicit `audit.retention_days: 0` in YAML  -> purge disabled.
  - `WEKNORA_AUDIT_RETENTION_DAYS=N` env override (incl. N=0).
  - `retention_days < 0` is rejected by ValidateConfig.

Tests cover the service Purge contract (no-op when disabled, cutoff
math, error propagation) and the runner lifecycle (Start no-op when
disabled, idempotent Start/Stop, ticker cadence, runOnce swallows
errors). 10 new tests, all green.

docs/rbac.md updated with the new YAML / env / behaviour.

Refs: #1303
This commit is contained in:
wizardchen
2026-05-15 20:10:47 +08:00
committed by lyingbug
parent cf6c28fe7e
commit ff1f03ce0d
8 changed files with 563 additions and 0 deletions
+14
View File
@@ -116,6 +116,13 @@ auth:
# invite_only — public registration is rejected; new users
# enter only via /tenants/:id/members invitations.
registration_mode: self_serve
audit:
# Days of audit history retained. A daily background sweep deletes
# rows older than this. Default 90 (set automatically when the
# `audit:` section is omitted from the YAML); set to 0 to disable
# the purge entirely (the table grows monotonically).
retention_days: 90
```
Environment overrides (always win over YAML):
@@ -124,6 +131,7 @@ Environment overrides (always win over YAML):
|--------------------------------------|--------------------------------|------------------------------|
| `WEKNORA_TENANT_ENABLE_RBAC` | `tenant.enable_rbac` | `true` / `false` |
| `WEKNORA_AUTH_REGISTRATION_MODE` | `auth.registration_mode` | `self_serve` / `invite_only` |
| `WEKNORA_AUDIT_RETENTION_DAYS` | `audit.retention_days` | non-negative integer |
The startup logger emits one line summarising both effective values
plus their override sources, so you can confirm at boot which mode
@@ -203,6 +211,12 @@ Built-in actions today:
The schema is intentionally generic so future PRs can add KB / agent /
chunk action constants without another migration.
A daily background goroutine (`AuditLogRetentionRunner`, `service/`)
sweeps rows older than `audit.retention_days`. The first sweep fires
~10 minutes after boot to stay out of the way of startup traffic; the
loop then runs every 24 h. The runner short-circuits when retention
is `0`, so disabling it costs zero DB round-trips.
## Route guards
Centralised in `internal/router/rbac.go` as `rbacGuards`:
@@ -104,3 +104,25 @@ func (r *auditLogRepository) CountSinceForDedup(
Count(&count).Error
return count, err
}
// DeleteOlderThan purges rows strictly older than cutoff in a single
// DELETE. The retention sweep (driven by the audit log service) calls
// it once a day with cutoff = now - retention_days.
//
// Tenant scope is intentionally not part of this signature: retention
// is a global ops policy, not a per-tenant choice. If we ever need
// per-tenant retention, we'd add a separate DeleteOlderThanForTenant
// rather than overload this primitive.
//
// Returns the number of rows affected so the caller can log the sweep
// outcome at INFO. Errors propagate verbatim — the caller decides
// whether they're terminal or transient.
func (r *auditLogRepository) DeleteOlderThan(ctx context.Context, cutoff time.Time) (int64, error) {
res := r.db.WithContext(ctx).
Where("created_at < ?", cutoff).
Delete(&types.AuditLog{})
if res.Error != nil {
return 0, res.Error
}
return res.RowsAffected, nil
}
+21
View File
@@ -128,3 +128,24 @@ func (s *auditLogService) List(
) ([]*types.AuditLog, error) {
return s.repo.List(ctx, tenantID, q)
}
// Purge deletes rows whose created_at is strictly older than
// `retentionDays` ago. retentionDays <= 0 short-circuits — operators
// who configured no retention pay zero database round-trips.
//
// The cutoff is computed off the service's clock (s.now) so tests
// can drive deterministic horizons without touching wall time.
//
// We intentionally do NOT batch the DELETE: at the volumes audit_logs
// realistically reaches in a 24h window, a single DELETE-with-index
// finishes in well under a second on Postgres. If the table ever
// grows large enough that a single sweep blocks vacuum, the repo
// helper is the place to add LIMIT-style chunking — the service stays
// simple.
func (s *auditLogService) Purge(ctx context.Context, retentionDays int) (int64, error) {
if retentionDays <= 0 {
return 0, nil
}
cutoff := s.now().Add(-time.Duration(retentionDays) * 24 * time.Hour)
return s.repo.DeleteOlderThan(ctx, cutoff)
}
@@ -0,0 +1,158 @@
package service
import (
"context"
"sync"
"time"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// AuditLogRetentionRunner sweeps audit_logs once a day, deleting rows
// older than `retentionDays`. It is a small, self-contained
// background goroutine — no robfig/cron, no asynq — because retention
// has no wall-clock alignment requirement: we just need "approximately
// daily, eventually". A bare time.Ticker keeps the dependency surface
// minimal.
//
// retentionDays <= 0 makes Start a no-op; this is the configured way
// to disable retention entirely. Validation happens at config-load
// time so by the time we're here a non-positive value is intentional.
type AuditLogRetentionRunner struct {
svc interfaces.AuditLogService
retentionDays int
interval time.Duration
startOnce sync.Once
stopOnce sync.Once
stopCh chan struct{}
doneCh chan struct{}
}
// auditLogPurgeInterval is the gap between sweeps. 24h is enough for
// a per-day retention horizon — the cutoff moves by 24h between runs
// so each sweep deletes one day's worth of rolled-off rows. Shortening
// this would just cause empty sweeps; lengthening it would pile up
// stale rows for a day.
const auditLogPurgeInterval = 24 * time.Hour
// auditLogPurgeStartupDelay holds the very first sweep until shortly
// after boot so we don't compete with migration-up traffic or other
// startup work. Long enough that the first DELETE doesn't fight the
// initial request flood; short enough that operators see the sweep
// fire on the same day they restart.
const auditLogPurgeStartupDelay = 10 * time.Minute
// NewAuditLogRetentionRunner constructs the runner with production
// defaults. retention_days is read from the config; passing the full
// *config.Config (rather than just an int) keeps the dig wiring trivial
// — it's the same shape as every other config-aware constructor in
// the container. The constructor only validates inputs; nothing fires
// until Start is called.
func NewAuditLogRetentionRunner(
cfg *config.Config, svc interfaces.AuditLogService,
) *AuditLogRetentionRunner {
retentionDays := 0
if cfg != nil && cfg.Audit != nil {
retentionDays = cfg.Audit.RetentionDays
}
return &AuditLogRetentionRunner{
svc: svc,
retentionDays: retentionDays,
interval: auditLogPurgeInterval,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
}
// Start spins up the background goroutine. Calling it more than once
// is a no-op (sync.Once), so container wiring that mistakenly invokes
// us twice doesn't double-purge. retentionDays <= 0 means the runner
// stays dormant — Stop will still complete cleanly.
func (r *AuditLogRetentionRunner) Start(ctx context.Context) {
if r == nil || r.svc == nil {
return
}
r.startOnce.Do(func() {
if r.retentionDays <= 0 {
logger.Infof(ctx,
"[audit-retention] disabled (retention_days=%d)", r.retentionDays)
close(r.doneCh)
return
}
logger.Infof(ctx,
"[audit-retention] starting daily sweep: retention_days=%d interval=%s",
r.retentionDays, r.interval)
go r.loop()
})
}
// Stop signals the loop to exit and blocks until it returns. Idempotent.
// If Start was never called, Stop returns immediately.
func (r *AuditLogRetentionRunner) Stop() {
if r == nil {
return
}
r.stopOnce.Do(func() {
close(r.stopCh)
})
<-r.doneCh
}
// loop runs the actual sweep cadence. Uses a fresh context.Background
// per iteration because the request-scoped ctx from Start would be
// cancelled the moment Start's caller returned — and that caller is
// container init.
func (r *AuditLogRetentionRunner) loop() {
defer close(r.doneCh)
startupTimer := time.NewTimer(auditLogPurgeStartupDelay)
defer startupTimer.Stop()
select {
case <-startupTimer.C:
case <-r.stopCh:
return
}
r.runOnce()
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
r.runOnce()
case <-r.stopCh:
return
}
}
}
// runOnce performs a single sweep. The DB call is given a generous
// timeout (30 s) so a stuck connection doesn't hold the goroutine
// hostage forever — if the sweep doesn't finish in 30 s we'll log
// and try again 24 h later. Errors are logged at WARN, not ERROR,
// because the table just keeps growing one more day; nothing breaks.
func (r *AuditLogRetentionRunner) runOnce() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
deleted, err := r.svc.Purge(ctx, r.retentionDays)
if err != nil {
logger.Warnf(ctx,
"[audit-retention] sweep failed: retention_days=%d err=%v",
r.retentionDays, err)
return
}
if deleted > 0 {
logger.Infof(ctx,
"[audit-retention] sweep complete: deleted=%d retention_days=%d",
deleted, r.retentionDays)
} else {
logger.Debugf(ctx,
"[audit-retention] sweep complete: deleted=0 retention_days=%d",
r.retentionDays)
}
}
@@ -0,0 +1,261 @@
package service
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// stubAuditRepoForRetention captures DeleteOlderThan calls. We embed
// the interface so any unstubbed method nil-panics — that's the same
// contract-drift signal stubAuditRepo uses, kept consistent across
// the package's audit log tests.
type stubAuditRepoForRetention struct {
interfaces.AuditLogRepository
mu sync.Mutex
calls []time.Time
deleted int64
deleteError error
}
func (s *stubAuditRepoForRetention) DeleteOlderThan(_ context.Context, cutoff time.Time) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.calls = append(s.calls, cutoff)
if s.deleteError != nil {
return 0, s.deleteError
}
return s.deleted, nil
}
func TestAuditLog_Purge_NoOpWhenRetentionDisabled(t *testing.T) {
// retention_days <= 0 must short-circuit before hitting the repo.
// Otherwise an "off" config would still issue a daily DELETE on
// every column < cutoff, which silently nukes every row when
// cutoff = now().
repo := &stubAuditRepoForRetention{}
clock := &fakeClock{t: time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)}
svc := &auditLogService{repo: repo, now: clock.Now}
for _, days := range []int{0, -1, -90} {
deleted, err := svc.Purge(context.Background(), days)
if err != nil {
t.Fatalf("Purge(%d) unexpected error: %v", days, err)
}
if deleted != 0 {
t.Fatalf("Purge(%d) returned non-zero count: %d", days, deleted)
}
}
if len(repo.calls) != 0 {
t.Fatalf("expected zero repo.DeleteOlderThan calls, got %d", len(repo.calls))
}
}
func TestAuditLog_Purge_UsesClockMinusRetention(t *testing.T) {
// The cutoff fed to the repo must be exactly retention_days × 24h
// before the service's clock. Off-by-day errors would silently
// retain too much (table grows) or delete too much (data loss).
repo := &stubAuditRepoForRetention{deleted: 42}
clock := &fakeClock{t: time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)}
svc := &auditLogService{repo: repo, now: clock.Now}
deleted, err := svc.Purge(context.Background(), 90)
if err != nil {
t.Fatalf("Purge: %v", err)
}
if deleted != 42 {
t.Fatalf("expected delete count 42 propagated, got %d", deleted)
}
if len(repo.calls) != 1 {
t.Fatalf("expected 1 repo call, got %d", len(repo.calls))
}
wantCutoff := clock.Now().Add(-90 * 24 * time.Hour)
if !repo.calls[0].Equal(wantCutoff) {
t.Fatalf("cutoff: want %v, got %v", wantCutoff, repo.calls[0])
}
}
func TestAuditLog_Purge_PropagatesRepoError(t *testing.T) {
// Retention failures must surface to the runner, which logs them
// at WARN. Silently swallowing the error would mask a degraded DB
// for days because the next sweep is 24h away.
repo := &stubAuditRepoForRetention{deleteError: errors.New("connection lost")}
clock := &fakeClock{t: time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)}
svc := &auditLogService{repo: repo, now: clock.Now}
if _, err := svc.Purge(context.Background(), 30); err == nil {
t.Fatalf("expected error to propagate from repo")
}
}
// purgeCountingService is a tiny in-test AuditLogService implementation
// that just counts Purge calls. The runner's loop is the only thing
// under test here, so we can ignore Log / LogDenied / List entirely.
type purgeCountingService struct {
interfaces.AuditLogService
calls atomic.Int64
}
func (p *purgeCountingService) Purge(_ context.Context, _ int) (int64, error) {
p.calls.Add(1)
return 0, nil
}
func TestAuditLogRetentionRunner_StartIsNoOpWhenDisabled(t *testing.T) {
// retention_days <= 0 keeps the goroutine asleep — Start logs a
// "disabled" line and Stop returns immediately. This is the
// configured way to turn retention off, and the runner must not
// kick a goroutine for it.
svc := &purgeCountingService{}
r := &AuditLogRetentionRunner{
svc: svc,
retentionDays: 0,
interval: time.Millisecond,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
r.Start(context.Background())
r.Stop()
if got := svc.calls.Load(); got != 0 {
t.Fatalf("expected 0 Purge calls when disabled, got %d", got)
}
}
func TestAuditLogRetentionRunner_StopIsIdempotent(t *testing.T) {
// Calling Stop twice must not panic — container shutdown ordering
// can race ResourceCleaner with other shutdown hooks, and we'd
// rather no-op than crash on the second call.
svc := &purgeCountingService{}
r := &AuditLogRetentionRunner{
svc: svc,
retentionDays: 0,
interval: time.Millisecond,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
r.Start(context.Background())
r.Stop()
r.Stop()
}
func TestAuditLogRetentionRunner_StartIsIdempotent(t *testing.T) {
// Container init that mistakenly invokes Start twice must not
// double-fire the loop. sync.Once guards this; the test pins the
// invariant.
svc := &purgeCountingService{}
r := &AuditLogRetentionRunner{
svc: svc,
retentionDays: 0,
interval: time.Millisecond,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
r.Start(context.Background())
r.Start(context.Background())
r.Stop()
}
func TestAuditLogRetentionRunner_NilSvcShortCircuits(t *testing.T) {
// Defensive: a misconfigured container (audit service couldn't
// be constructed) must not crash the app. Start with nil svc is
// a no-op. We don't call Stop here because Start never closes
// doneCh on the nil-svc path (returns before the once block runs);
// blocking on doneCh inside Stop would hang the test.
r := &AuditLogRetentionRunner{
retentionDays: 90,
interval: time.Millisecond,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
r.Start(context.Background())
}
// retentionRunnerWithImmediateStartup builds a runner whose startup
// delay has already elapsed, so the loop runs Purge immediately. We
// can't expose that as a public knob (production should not skip the
// startup grace window), so the test reaches inside the package to
// build the runner with a custom interval.
func retentionRunnerWithImmediateStartup(svc interfaces.AuditLogService, days int) *AuditLogRetentionRunner {
return &AuditLogRetentionRunner{
svc: svc,
retentionDays: days,
interval: 30 * time.Millisecond,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
}
// runLoopWithoutStartupDelay drives the runner's loop directly, skipping
// the 10-minute startup pause that production uses to stay out of the
// way of boot traffic. Tests need to fire the sweep immediately.
func (r *AuditLogRetentionRunner) runLoopWithoutStartupDelay() {
defer close(r.doneCh)
r.runOnce()
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
r.runOnce()
case <-r.stopCh:
return
}
}
}
func TestAuditLogRetentionRunner_PurgesOnTickerCadence(t *testing.T) {
// The actual cadence test: with the startup delay collapsed and
// the interval shrunk to 30ms, we expect at least 2 Purge calls
// over a 100ms window. This pins the headline behaviour ("yes,
// the goroutine actually fires Purge over time") without coupling
// to a specific count, which would be flaky on a slow CI runner.
svc := &purgeCountingService{}
r := retentionRunnerWithImmediateStartup(svc, 30)
go r.runLoopWithoutStartupDelay()
time.Sleep(100 * time.Millisecond)
r.Stop()
if got := svc.calls.Load(); got < 2 {
t.Fatalf("expected >=2 Purge calls in 100ms with 30ms interval, got %d", got)
}
}
func TestAuditLogRetentionRunner_RunOnceLogsButDoesNotPanicOnError(t *testing.T) {
// runOnce must swallow Purge errors — a stuck DB shouldn't propagate
// a panic up out of the goroutine and crash the app. The behaviour
// is "log at WARN and try again next tick".
repo := &stubAuditRepoForRetention{deleteError: errors.New("simulated")}
clock := &fakeClock{t: time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)}
svc := &auditLogService{repo: repo, now: clock.Now}
r := retentionRunnerWithImmediateStartup(svc, 30)
r.runOnce() // must not panic
if got := len(repo.calls); got != 1 {
t.Fatalf("expected runOnce to call repo once, got %d", got)
}
}
// Ensure the test stubs satisfy the production type: if the interface
// drifts, this assignment will fail to compile and tell us immediately.
var _ interfaces.AuditLogRepository = (*stubAuditRepo)(nil)
var _ interfaces.AuditLogRepository = (*stubAuditRepoForRetention)(nil)
// Sanity check the audit entry struct's fields stay attached to the
// retention path — guards against a refactor that drops CreatedAt
// from the model and silently breaks the cutoff filter.
func TestAuditLogModel_HasCreatedAtField(t *testing.T) {
entry := types.AuditLog{}
entry.CreatedAt = time.Now()
if entry.CreatedAt.IsZero() {
t.Fatal("AuditLog.CreatedAt must be assignable")
}
}
+54
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
@@ -21,6 +22,7 @@ type Config struct {
KnowledgeBase *KnowledgeBaseConfig `yaml:"knowledge_base" json:"knowledge_base"`
Tenant *TenantConfig `yaml:"tenant" json:"tenant"`
Auth *AuthConfig `yaml:"auth" json:"auth"`
Audit *AuditConfig `yaml:"audit" json:"audit"`
OIDCAuth *OIDCAuthConfig `yaml:"oidc_auth" json:"oidc_auth"`
Models []ModelConfig `yaml:"models" json:"models"`
VectorDatabase *VectorDatabaseConfig `yaml:"vector_database" json:"vector_database"`
@@ -182,6 +184,20 @@ type TenantConfig struct {
EnableRBAC bool `yaml:"enable_rbac" json:"enable_rbac"`
}
// AuditConfig governs durable audit log behaviour. Writes happen on
// every member-management mutation and on RBAC denials (when
// EnableRBAC is true); the table grows monotonically unless this
// section turns on retention.
type AuditConfig struct {
// RetentionDays is how many days of audit history to keep. Older
// rows are deleted by a daily background sweep.
// > 0 — purge rows whose created_at < NOW() - retention_days.
// = 0 — disable purge entirely (the pre-rollout default).
// < 0 — invalid; ValidateConfig rejects it.
// Default: 90 (set by applyAuditDefaults when the section is omitted).
RetentionDays int `yaml:"retention_days" json:"retention_days"`
}
// AuthConfig governs the user authentication entry points.
type AuthConfig struct {
// RegistrationMode controls who may call POST /auth/register.
@@ -472,6 +488,7 @@ func LoadConfig() (*Config, error) {
applyAgentEnvOverrides(&cfg)
applyKnowledgeBaseEnvOverrides(&cfg)
applyAuthAndTenantDefaults(&cfg)
applyAuditDefaults(&cfg)
if err := ValidateConfig(&cfg); err != nil {
return nil, err
@@ -524,6 +541,11 @@ func ValidateConfig(cfg *Config) error {
}
}
if cfg.Audit != nil && cfg.Audit.RetentionDays < 0 {
errs = append(errs, fmt.Sprintf("audit.retention_days must be >= 0 (got %d); use 0 to disable purge",
cfg.Audit.RetentionDays))
}
if cfg.Conversation != nil {
if cfg.Conversation.EmbeddingTopK < 0 {
errs = append(errs, "conversation.embedding_top_k must be >= 0")
@@ -693,6 +715,38 @@ func applyAuthAndTenantDefaults(cfg *Config) {
}
}
// applyAuditDefaults fills in defaults for the Audit config section
// and applies the env override commonly used to extend or disable
// retention without editing config.yaml.
//
// Defaults:
// - When the `audit:` section is omitted entirely from YAML,
// RetentionDays = 90 (purge rows older than 90 days).
//
// Operator intent is otherwise preserved: an explicit
// `audit.retention_days: 0` in YAML means "disable the purge", which
// is a supported posture for compliance use cases that handle archival
// off-database.
//
// Env overrides (when set and parseable; out-of-range is ignored):
// - WEKNORA_AUDIT_RETENTION_DAYS (non-negative integer)
func applyAuditDefaults(cfg *Config) {
// Section omitted entirely -> apply the default and no env wiring
// is needed for the most common path.
if cfg.Audit == nil {
cfg.Audit = &AuditConfig{RetentionDays: 90}
}
// Env override always wins, but only when explicitly set so a
// stale shell variable doesn't suddenly disable the purge for a
// future deployment that committed a real value.
if value := strings.TrimSpace(os.Getenv("WEKNORA_AUDIT_RETENTION_DAYS")); value != "" {
if n, err := strconv.Atoi(value); err == nil && n >= 0 {
cfg.Audit.RetentionDays = n
}
}
}
// into actual prompt text content. Only xxx_id fields are used;
// no fallback to default templates.
func backfillConversationDefaults(cfg *Config) {
+22
View File
@@ -172,6 +172,7 @@ func BuildContainer(container *dig.Container) *dig.Container {
must(container.Provide(service.NewTenantService))
must(container.Provide(service.NewTenantMemberService))
must(container.Provide(service.NewAuditLogService))
must(container.Provide(service.NewAuditLogRetentionRunner))
must(container.Provide(service.NewKnowledgeBaseService))
must(container.Provide(service.NewOrganizationService))
must(container.Provide(service.NewKBShareService)) // KBShareService must be registered before KnowledgeService and KnowledgeTagService
@@ -262,6 +263,8 @@ func BuildContainer(container *dig.Container) *dig.Container {
must(container.Provide(service.NewDataSourceService))
must(container.Invoke(startDataSourceScheduler))
logger.Debugf(ctx, "[Container] Data source sync framework registered")
must(container.Invoke(startAuditLogRetention))
logger.Debugf(ctx, "[Container] Audit log retention runner registered")
must(container.Provide(chatpipeline.NewEventManager))
must(container.Invoke(chatpipeline.NewPluginSearch))
must(container.Invoke(chatpipeline.NewPluginRerank))
@@ -1322,3 +1325,22 @@ func startDataSourceScheduler(scheduler *datasource.Scheduler, cleaner interface
return nil
})
}
// startAuditLogRetention spins up the daily audit_logs purge sweep
// and registers shutdown cleanup. Mirrors the data-source-scheduler
// pattern: container init kicks the goroutine, ResourceCleaner stops
// it during graceful shutdown so a SIGTERM during a sweep doesn't
// orphan the goroutine.
//
// retention_days <= 0 is the configured way to disable retention;
// the runner short-circuits Start() on that path so we don't need
// to gate the wiring here.
func startAuditLogRetention(
runner *service.AuditLogRetentionRunner, cleaner interfaces.ResourceCleaner,
) {
runner.Start(context.Background())
cleaner.RegisterWithName("AuditLogRetentionRunner", func() error {
runner.Stop()
return nil
})
}
+11
View File
@@ -39,6 +39,12 @@ type AuditLogRepository interface {
requestPath string,
since time.Time,
) (int64, error)
// DeleteOlderThan removes audit rows whose created_at is strictly
// before cutoff and returns the affected row count. It is the
// retention primitive driven by the daily background sweep.
// Implementations should delete in a single statement (no per-row
// fetch) so the long-tail cost stays at "one DELETE per sweep".
DeleteOlderThan(ctx context.Context, cutoff time.Time) (int64, error)
}
// AuditLogService is the high-level audit API the rest of the codebase
@@ -60,4 +66,9 @@ type AuditLogService interface {
requiredRole types.TenantRole,
) error
List(ctx context.Context, tenantID uint64, q *AuditLogQuery) ([]*types.AuditLog, error)
// Purge deletes rows whose created_at is strictly older than the
// retention horizon. retentionDays <= 0 makes the call a no-op,
// which keeps the daily sweep cheap when retention is disabled.
// Returns rows deleted; transient repo errors propagate.
Purge(ctx context.Context, retentionDays int) (int64, error)
}