Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
171 lines
6.2 KiB
Go
171 lines
6.2 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// auditLogService is the high-level wrapper around AuditLogRepository.
|
|
// It owns:
|
|
// - timestamp defaulting (Log fills CreatedAt if zero so callers
|
|
// don't have to).
|
|
// - the 1-minute sliding-window dedup that LogDenied applies to keep
|
|
// a probing client from filling the audit_logs table.
|
|
//
|
|
// The service is intentionally nil-tolerant when consumed: the
|
|
// tenant_member service holds it as an optional field, so a future
|
|
// container reshuffle that constructs tenant_member before audit_log
|
|
// won't crash. Callers should still aim to inject a real instance —
|
|
// the nil path is a degraded mode, not a default.
|
|
type auditLogService struct {
|
|
repo interfaces.AuditLogRepository
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewAuditLogService constructs the production service.
|
|
func NewAuditLogService(repo interfaces.AuditLogRepository) interfaces.AuditLogService {
|
|
return &auditLogService{repo: repo, now: time.Now}
|
|
}
|
|
|
|
// denyDedupWindow caps how often LogDenied will write a durable row
|
|
// for the same (tenant, actor, path, action) tuple. 1 minute is
|
|
// short enough that bursts across distinct paths still each show up
|
|
// in the table (one row per path per minute), long enough that a
|
|
// single hammered endpoint at 100 RPS produces 1 row/minute, not 6000.
|
|
const denyDedupWindow = 1 * time.Minute
|
|
|
|
// Log is the canonical write path. The repo's Create defaults
|
|
// CreatedAt at the SQL level, but we also fill it here so tests and
|
|
// callers that read entry.CreatedAt right after Log() get a sensible
|
|
// value without round-tripping through the database.
|
|
func (s *auditLogService) Log(ctx context.Context, entry *types.AuditLog) error {
|
|
if entry == nil {
|
|
return fmt.Errorf("audit log: nil entry")
|
|
}
|
|
if entry.Action == "" {
|
|
return fmt.Errorf("audit log: action is required")
|
|
}
|
|
if entry.Outcome == "" {
|
|
entry.Outcome = types.AuditOutcomeSuccess
|
|
}
|
|
if entry.CreatedAt.IsZero() {
|
|
entry.CreatedAt = s.now()
|
|
}
|
|
if err := s.repo.Create(ctx, entry); err != nil {
|
|
// Log loudly but do NOT propagate the error to the caller in
|
|
// the production wiring (see callers in tenant_member service
|
|
// and rbac middleware — both ignore the return). Audit failure
|
|
// must never break the underlying business operation.
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|
"audit_action": entry.Action,
|
|
"audit_target": entry.TargetID,
|
|
})
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LogDenied records a middleware-level rejection. Subject to a
|
|
// 1-minute sliding-window dedup keyed by
|
|
// (tenant_id, actor_user_id, action, request_path) so a probing
|
|
// client cannot flood the table.
|
|
//
|
|
// The non-durable advisory log line `[rbac] role insufficient: ...`
|
|
// continues to fire on every reject (in middleware/rbac.go) — the
|
|
// dedup only suppresses durable writes, not stderr observability.
|
|
func (s *auditLogService) LogDenied(
|
|
ctx context.Context,
|
|
c *gin.Context,
|
|
tenantID uint64,
|
|
actorUserID, actorRole string,
|
|
requiredRole types.TenantRole,
|
|
) error {
|
|
rawPath := ""
|
|
requestMethod := ""
|
|
// dedupPath keys both the sliding-window dedup AND the persisted
|
|
// request_path column. We prefer the route TEMPLATE (gin's
|
|
// c.FullPath, e.g. "/api/v1/knowledge-bases/:id") over the raw URL:
|
|
// without this, an attacker iterating UUIDs in the URL produces a
|
|
// fresh dedup key per request, defeating the window and ballooning
|
|
// audit_logs. The raw URL is preserved inside the Details JSON for
|
|
// forensics, so we don't lose "which resource was probed".
|
|
dedupPath := ""
|
|
if c != nil && c.Request != nil {
|
|
rawPath = c.Request.URL.Path
|
|
requestMethod = c.Request.Method
|
|
dedupPath = c.FullPath()
|
|
if dedupPath == "" {
|
|
// No matched route (e.g. 404 path that still hit the
|
|
// middleware via a catch-all). Fall back to the raw path so
|
|
// the row remains non-empty for the audit reader.
|
|
dedupPath = rawPath
|
|
}
|
|
}
|
|
|
|
// Dedup probe: skip the durable write if this exact tuple already
|
|
// has a row in the trailing window. Failure here is non-fatal —
|
|
// degraded behaviour is "write a duplicate" which is preferable to
|
|
// "skip the audit because the count failed".
|
|
since := s.now().Add(-denyDedupWindow)
|
|
if n, err := s.repo.CountSinceForDedup(
|
|
ctx, tenantID, actorUserID, types.AuditActionAccessDenied, dedupPath, since,
|
|
); err == nil && n > 0 {
|
|
return nil
|
|
}
|
|
|
|
detailsMap := map[string]string{"required_role": string(requiredRole)}
|
|
if rawPath != "" && rawPath != dedupPath {
|
|
detailsMap["raw_path"] = rawPath
|
|
}
|
|
details, _ := json.Marshal(detailsMap)
|
|
return s.Log(ctx, &types.AuditLog{
|
|
TenantID: tenantID,
|
|
ActorUserID: actorUserID,
|
|
ActorRole: actorRole,
|
|
Action: types.AuditActionAccessDenied,
|
|
RequestPath: dedupPath,
|
|
RequestMethod: requestMethod,
|
|
Outcome: types.AuditOutcomeDenied,
|
|
Details: types.JSON(details),
|
|
})
|
|
}
|
|
|
|
// List proxies to the repository. The handler layer applies the
|
|
// PathTenantMatch + Admin guard before this is reached, so we don't
|
|
// re-check tenant scope here.
|
|
func (s *auditLogService) List(
|
|
ctx context.Context,
|
|
tenantID uint64,
|
|
q *interfaces.AuditLogQuery,
|
|
) ([]*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)
|
|
}
|