mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-01 14:53:07 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user