Codex/milestone 9 #11
+47
-2
@@ -9,16 +9,19 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentclient"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/web"
|
||||
)
|
||||
@@ -55,6 +58,21 @@ func run(logger *slog.Logger) error {
|
||||
var handler http.Handler
|
||||
var lifecycle *instance.LifecycleService
|
||||
var backupService *backup.Service
|
||||
auditService := audit.New(db)
|
||||
var notificationService *notification.Service
|
||||
if keyFile := os.Getenv("DOGAMA_MASTER_KEY_FILE"); keyFile != "" {
|
||||
key, keyErr := os.ReadFile(keyFile)
|
||||
if keyErr != nil {
|
||||
return errors.New("read encryption key file")
|
||||
}
|
||||
key = bytes.TrimSpace(key)
|
||||
notificationService, keyErr = notification.New(db, key)
|
||||
if keyErr != nil {
|
||||
return keyErr
|
||||
}
|
||||
} else {
|
||||
logger.Warn("notification channel configuration disabled: DOGAMA_MASTER_KEY_FILE is unset", "event", "notification.disabled")
|
||||
}
|
||||
importService, err := importexport.New(repository, environment("DOGAMA_IMPORTS_ROOT", "/var/lib/dogama/imports/staging"), serversRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -62,7 +80,6 @@ func run(logger *slog.Logger) error {
|
||||
agentURL, tokenFile := os.Getenv("DOGAMA_AGENT_URL"), os.Getenv("DOGAMA_AGENT_TOKEN_FILE")
|
||||
if agentURL == "" && tokenFile == "" {
|
||||
logger.Warn("instance lifecycle disabled", "event", "lifecycle.disabled")
|
||||
handler, err = web.NewHandlerWithRepositoryAndImports(auth.New(db), repository, importService, logger)
|
||||
} else {
|
||||
if agentURL == "" || tokenFile == "" {
|
||||
return errors.New("DOGAMA_AGENT_URL and DOGAMA_AGENT_TOKEN_FILE must be configured together")
|
||||
@@ -90,8 +107,8 @@ func run(logger *slog.Logger) error {
|
||||
logger.Warn("instance reconciliation incomplete", "event", "lifecycle.reconcile.failed")
|
||||
}
|
||||
cancel()
|
||||
handler, err = web.NewHandlerWithLifecycleAndBackup(auth.New(db), repository, agent, backupService, importService, logger)
|
||||
}
|
||||
handler, err = web.NewHandlerComplete(auth.New(db), repository, lifecycle, backupService, importService, auditService, notificationService, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -102,6 +119,7 @@ func run(logger *slog.Logger) error {
|
||||
go runBackupScheduler(ctx, backupService, logger)
|
||||
}
|
||||
go runImportCleanup(ctx, importService, logger)
|
||||
go runObservabilityScheduler(ctx, auditService, notificationService, logger)
|
||||
server := &http.Server{
|
||||
Addr: listenAddress,
|
||||
Handler: handler,
|
||||
@@ -128,6 +146,33 @@ func run(logger *slog.Logger) error {
|
||||
}
|
||||
}
|
||||
|
||||
func runObservabilityScheduler(ctx context.Context, auditService *audit.Service, notificationService *notification.Service, logger *slog.Logger) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
lastPurgeDay := ""
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
if notificationService != nil {
|
||||
if err := notificationService.RunDue(ctx); err != nil {
|
||||
logger.Warn("notification delivery run incomplete", "event", "notification.scheduler.failed")
|
||||
}
|
||||
}
|
||||
day := now.UTC().Format("2006-01-02")
|
||||
if day != lastPurgeDay {
|
||||
if deleted, err := auditService.RunRetention(ctx); err != nil {
|
||||
logger.Warn("audit retention incomplete", "event", "audit.retention.failed")
|
||||
} else if deleted > 0 {
|
||||
_ = auditService.Record(ctx, audit.Event{ActorLabel: "system", Action: "audit.retention.purge", Outcome: "allowed", Summary: map[string]string{"deleted_count": strconv.FormatInt(deleted, 10)}})
|
||||
}
|
||||
lastPurgeDay = day
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runImportCleanup(ctx context.Context, service *importexport.Service, logger *slog.Logger) {
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
+12
-5
@@ -4,9 +4,9 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
|
||||
## Baseline
|
||||
|
||||
- Current reference: milestone 8 implementation `7f5fa30` after baseline `c820c9c`.
|
||||
- Released SQLite migrations: `0001` through `0008`; never rewrite them.
|
||||
- Roadmap milestones 1-8 are implemented.
|
||||
- Current reference: milestone 9 working branch after merged milestone 8 baseline `1e226d3`.
|
||||
- Released SQLite migrations: `0001` through `0009`; never rewrite them.
|
||||
- Roadmap milestones 1-9 are implemented.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -28,6 +28,9 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
- Controlled digest-aware game updates with confirmation, policy-driven pre-update backups, readiness verification, mod warnings and automatic container-plan rollback.
|
||||
- Redacted configuration history retained to the latest 10 revisions, with pinned-template revalidation and immediate or deferred rollback.
|
||||
- Declarative Steam Workshop item configuration with numeric-ID validation, stable ordering and backend `mods.manage` enforcement.
|
||||
- Encrypted write-only SMTP, generic HTTPS webhook and Discord channels with event filters, queued test delivery, bounded retry and redacted terminal errors.
|
||||
- SSRF-resistant HTTPS webhook delivery with redirect/address revalidation, event IDs, timestamps and optional HMAC-SHA256 signatures.
|
||||
- Compact allow-listed audit events for authentication and significant mutations, administrator filtering, bounded manual purge, daily retention and maximum-count enforcement.
|
||||
|
||||
## Durable decisions
|
||||
|
||||
@@ -44,10 +47,14 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
- The main app never gains Docker-socket access; the agent remains deny-by-default and independently validates privileged plan fields.
|
||||
- Update candidates are explicit `tag@sha256:digest` references. Mutable tags alone are rejected; automatic updates remain disabled.
|
||||
- Mod configuration is data-only. Provider commands, scripts and arbitrary download URLs are forbidden.
|
||||
- Notification configuration is unavailable unless `DOGAMA_MASTER_KEY_FILE` contains exactly 32 bytes; ciphertext is authenticated AES-GCM and secrets are never returned by list APIs.
|
||||
- Notification delivery attempts are capped at five with exponential minute-scale backoff and never determine the originating operation result.
|
||||
- Audit retention defaults to 30 days and 10,000 entries; zero explicitly selects unlimited retention/count within documented bounds.
|
||||
|
||||
## Known limitations and debt
|
||||
|
||||
- Notification channels, audit delivery/retention UI and release hardening remain roadmap work.
|
||||
- Release hardening remains roadmap work.
|
||||
- Scheduled backup outcomes and repeated authentication blocks are audited/logged, but broader scheduler-origin notification coverage remains intentionally limited to events emitted by implemented workflows.
|
||||
- The web interface is intentionally modest; several advanced workflows are API-first.
|
||||
- Linux is the deployment target. Native Windows execution of the full Go suite is blocked by Unix `Statfs` code; use Linux/WSL/CI for complete execution.
|
||||
- `staticcheck`, `golangci-lint` and Python specification dependencies may not be installed on every development host; report missing tooling rather than silently skipping or installing it.
|
||||
@@ -61,5 +68,5 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
|
||||
## Next known work
|
||||
|
||||
- Roadmap milestone 9: notifications and light audit trail.
|
||||
- Roadmap milestone 10: security hardening, end-to-end tests, contributor documentation and release packaging.
|
||||
- Update this file at the end of every merged milestone or durable architectural change; keep it compact and remove stale statements.
|
||||
|
||||
@@ -28,7 +28,7 @@ tests/integration/
|
||||
|
||||
## Initial application development
|
||||
|
||||
The initial main application requires Go 1.25. SQLite is provided by the pure-Go `modernc.org/sqlite` driver, so neither cgo nor a system SQLite development library is required. It reads bootstrap settings from `DOGAMA_LISTEN_ADDRESS` (default `:8080`), `DOGAMA_DATABASE_PATH` (default `dogama.db`), `DOGAMA_AGENT_URL` and `DOGAMA_AGENT_TOKEN_FILE`. The two agent settings must either both be present or both be absent; lifecycle routes remain disabled when developing without an agent. Run it with:
|
||||
The initial main application requires Go 1.25. SQLite is provided by the pure-Go `modernc.org/sqlite` driver, so neither cgo nor a system SQLite development library is required. It reads bootstrap settings from `DOGAMA_LISTEN_ADDRESS` (default `:8080`), `DOGAMA_DATABASE_PATH` (default `dogama.db`), `DOGAMA_AGENT_URL`, `DOGAMA_AGENT_TOKEN_FILE` and `DOGAMA_MASTER_KEY_FILE`. The two agent settings must either both be present or both be absent; lifecycle routes remain disabled when developing without an agent. The master-key file must contain exactly 32 bytes and enables encrypted notification-channel configuration; audit remains available without it. Run it with:
|
||||
|
||||
```sh
|
||||
go run ./cmd/dogama
|
||||
|
||||
@@ -10,6 +10,14 @@ Administrators configure channels entirely in the UI:
|
||||
|
||||
Channel secrets are encrypted and write-only. A test action sends a clearly marked test message and reports a redacted result.
|
||||
|
||||
The main application reads the 32-byte authenticated-encryption key from
|
||||
`DOGAMA_MASTER_KEY_FILE`. Without that external key, audit remains available
|
||||
but channel configuration and delivery are disabled. Generic and Discord
|
||||
webhooks require HTTPS; resolution, redirects and every resolved address reject
|
||||
loopback, private, link-local, multicast and unspecified networks. Generic
|
||||
webhooks carry `X-DoGaMa-Event-ID`, `X-DoGaMa-Timestamp` and, when a signing
|
||||
secret is configured, an HMAC-SHA256 `X-DoGaMa-Signature`.
|
||||
|
||||
## Events and filtering
|
||||
|
||||
Suggested configurable events:
|
||||
@@ -58,5 +66,8 @@ Entries are compact and use allow-listed structured summaries. Player IDs may be
|
||||
|
||||
Audit retention defaults to 30 days and is globally administrator-configurable. An optional maximum count prevents unbounded growth. Purge runs daily and records one aggregate audit event, not an event per deleted row. Unlimited retention requires an explicit warning and displays database usage. Administrators may manually purge by date with confirmation.
|
||||
|
||||
Technical application logs go to stdout/stderr and use Docker log rotation. Their level and retention are separate from SQLite audit policy.
|
||||
The V1 bounds are 0–3650 retention days and 0–1,000,000 entries; zero means
|
||||
unlimited. The viewer returns at most 200 entries per request and supports time,
|
||||
actor, instance, action and outcome filters through the administration API.
|
||||
|
||||
Technical application logs go to stdout/stderr and use Docker log rotation. Their level and retention are separate from SQLite audit policy.
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// Package audit stores the deliberately small, redacted security audit trail.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
ActorID string `json:"actor_id"`
|
||||
ActorLabel string `json:"actor_label"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Action string `json:"action"`
|
||||
Outcome string `json:"outcome"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Summary map[string]string `json:"summary"`
|
||||
}
|
||||
|
||||
type Filter struct {
|
||||
ActorID, InstanceID, Action, Outcome string
|
||||
Since, Until time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
RetentionDays int `json:"retention_days"`
|
||||
MaximumCount int `json:"maximum_count"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(db *sql.DB) *Service { return &Service{db: db, now: time.Now} }
|
||||
|
||||
var allowedSummaryKeys = map[string]bool{"target_id": true, "target_name": true, "channel_type": true, "event_type": true, "reason_code": true, "deleted_count": true, "before": true, "after": true}
|
||||
|
||||
func (s *Service) Record(ctx context.Context, event Event) error {
|
||||
if event.Action == "" || (event.Outcome != "allowed" && event.Outcome != "denied" && event.Outcome != "failed") {
|
||||
return errors.New("invalid audit event")
|
||||
}
|
||||
clean := map[string]string{}
|
||||
for key, value := range event.Summary {
|
||||
if allowedSummaryKeys[key] && len(value) <= 200 {
|
||||
clean[key] = value
|
||||
}
|
||||
}
|
||||
body, _ := json.Marshal(clean)
|
||||
when := event.OccurredAt
|
||||
if when.IsZero() {
|
||||
when = s.now().UTC()
|
||||
}
|
||||
var actor, instance any
|
||||
if event.ActorID != "" {
|
||||
actor = event.ActorID
|
||||
}
|
||||
if event.InstanceID != "" {
|
||||
instance = event.InstanceID
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO audit_events(id,occurred_at,actor_id,actor_label,instance_id,action,outcome,summary_json) VALUES(?,?,?,?,?,?,?,?)`, randomID(), when.Format(time.RFC3339Nano), actor, event.ActorLabel, instance, event.Action, event.Outcome, string(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("record audit event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, f Filter) ([]Event, error) {
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
clauses, args := []string{"1=1"}, []any{}
|
||||
for _, item := range []struct{ column, value string }{{"actor_id", f.ActorID}, {"instance_id", f.InstanceID}, {"action", f.Action}, {"outcome", f.Outcome}} {
|
||||
if item.value != "" {
|
||||
clauses = append(clauses, item.column+"=?")
|
||||
args = append(args, item.value)
|
||||
}
|
||||
}
|
||||
if !f.Since.IsZero() {
|
||||
clauses = append(clauses, "occurred_at>=?")
|
||||
args = append(args, f.Since.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
if !f.Until.IsZero() {
|
||||
clauses = append(clauses, "occurred_at<?")
|
||||
args = append(args, f.Until.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
args = append(args, limit)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,occurred_at,COALESCE(actor_id,''),actor_label,COALESCE(instance_id,''),action,outcome,summary_json FROM audit_events WHERE `+strings.Join(clauses, " AND ")+` ORDER BY occurred_at DESC,id DESC LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list audit events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var events []Event
|
||||
for rows.Next() {
|
||||
var e Event
|
||||
var occurred, body string
|
||||
if err := rows.Scan(&e.ID, &occurred, &e.ActorID, &e.ActorLabel, &e.InstanceID, &e.Action, &e.Outcome, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.OccurredAt, _ = time.Parse(time.RFC3339Nano, occurred)
|
||||
_ = json.Unmarshal([]byte(body), &e.Summary)
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) Policy(ctx context.Context) (Policy, error) {
|
||||
var body string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value_json FROM system_settings WHERE key='audit_policy'`).Scan(&body)
|
||||
if err != nil {
|
||||
return Policy{}, err
|
||||
}
|
||||
var p Policy
|
||||
var raw struct {
|
||||
RetentionDays int `json:"retention_days"`
|
||||
MaximumCount int `json:"maximum_count"`
|
||||
}
|
||||
if err = json.Unmarshal([]byte(body), &raw); err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.RetentionDays, p.MaximumCount = raw.RetentionDays, raw.MaximumCount
|
||||
return p, nil
|
||||
}
|
||||
func (s *Service) SetPolicy(ctx context.Context, p Policy) error {
|
||||
if p.RetentionDays < 0 || p.RetentionDays > 3650 || p.MaximumCount < 0 || p.MaximumCount > 1000000 {
|
||||
return errors.New("audit policy is out of bounds")
|
||||
}
|
||||
body, _ := json.Marshal(map[string]int{"retention_days": p.RetentionDays, "maximum_count": p.MaximumCount})
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE system_settings SET value_json=?,revision=revision+1,updated_at=? WHERE key='audit_policy'`, body, s.now().UTC().Format(time.RFC3339Nano))
|
||||
return err
|
||||
}
|
||||
func (s *Service) Purge(ctx context.Context, before time.Time) (int64, error) {
|
||||
if before.IsZero() || before.After(s.now().UTC()) {
|
||||
return 0, errors.New("invalid audit purge boundary")
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM audit_events WHERE occurred_at < ?`, before.UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
func (s *Service) RunRetention(ctx context.Context) (int64, error) {
|
||||
p, err := s.Policy(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var total int64
|
||||
if p.RetentionDays > 0 {
|
||||
n, e := s.Purge(ctx, s.now().UTC().AddDate(0, 0, -p.RetentionDays))
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
total += n
|
||||
}
|
||||
if p.MaximumCount > 0 {
|
||||
result, e := s.db.ExecContext(ctx, `DELETE FROM audit_events WHERE id IN (SELECT id FROM audit_events ORDER BY occurred_at DESC,id DESC LIMIT -1 OFFSET ?)`, p.MaximumCount)
|
||||
if e != nil {
|
||||
return total, e
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
total += n
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
func randomID() string {
|
||||
b := make([]byte, 18)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package audit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
)
|
||||
|
||||
func TestRecordFiltersSummaryAndRetention(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
service := audit.New(db)
|
||||
old := time.Now().UTC().AddDate(0, 0, -40)
|
||||
if err := service.Record(ctx, audit.Event{OccurredAt: old, ActorLabel: "admin", Action: "instance.update", Outcome: "allowed", Summary: map[string]string{"target_name": "Palworld", "secret": "must-not-persist"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events, err := service.List(ctx, audit.Filter{Action: "instance.update"})
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("events=%#v err=%v", events, err)
|
||||
}
|
||||
if events[0].Summary["target_name"] != "Palworld" || events[0].Summary["secret"] != "" {
|
||||
t.Fatalf("summary was not allow-listed: %#v", events[0].Summary)
|
||||
}
|
||||
if err := service.SetPolicy(ctx, audit.Policy{RetentionDays: 30, MaximumCount: 100}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deleted, err := service.RunRetention(ctx)
|
||||
if err != nil || deleted != 1 {
|
||||
t.Fatalf("deleted=%d err=%v", deleted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyAndPurgeBounds(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
service := audit.New(db)
|
||||
if err := service.SetPolicy(ctx, audit.Policy{RetentionDays: -1}); err == nil {
|
||||
t.Fatal("negative retention accepted")
|
||||
}
|
||||
if _, err := service.Purge(ctx, time.Now().Add(time.Hour)); err == nil {
|
||||
t.Fatal("future purge accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
// Package notification manages encrypted channels and bounded asynchronous delivery.
|
||||
package notification
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/smtp"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Channel struct {
|
||||
ID, Name, Type string
|
||||
Enabled bool
|
||||
Events []string
|
||||
Configured bool
|
||||
}
|
||||
type Input struct {
|
||||
Name, Type string
|
||||
Enabled bool
|
||||
Events []string
|
||||
Config map[string]string
|
||||
}
|
||||
type Event struct{ Type, Title, Message, InstanceName, OperationID string }
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
aead cipher.AEAD
|
||||
client *http.Client
|
||||
resolver *net.Resolver
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(db *sql.DB, key []byte) (*Service, error) {
|
||||
if len(key) != 32 {
|
||||
return nil, errors.New("notification encryption key must be exactly 32 bytes")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Service{db: db, aead: aead, resolver: net.DefaultResolver, now: time.Now}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.DialContext = s.dialSafe
|
||||
s.client = &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 3 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
return s.validateURL(req.Context(), req.URL)
|
||||
}}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Service) Upsert(ctx context.Context, id string, in Input) (Channel, error) {
|
||||
if strings.TrimSpace(in.Name) == "" || !validType(in.Type) {
|
||||
return Channel{}, errors.New("invalid notification channel")
|
||||
}
|
||||
if err := validateConfigShape(in.Type, in.Config); err != nil {
|
||||
return Channel{}, err
|
||||
}
|
||||
encrypted, err := s.seal(in.Config)
|
||||
if err != nil {
|
||||
return Channel{}, err
|
||||
}
|
||||
events, _ := json.Marshal(normalizeEvents(in.Events))
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
if id == "" {
|
||||
id = randomID()
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO notification_channels(id,name,type,enabled,encrypted_config,event_filter_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,type=excluded.type,enabled=excluded.enabled,encrypted_config=excluded.encrypted_config,event_filter_json=excluded.event_filter_json,updated_at=excluded.updated_at`, id, strings.TrimSpace(in.Name), in.Type, in.Enabled, encrypted, string(events), now, now)
|
||||
if err != nil {
|
||||
return Channel{}, fmt.Errorf("save notification channel: %w", err)
|
||||
}
|
||||
return Channel{ID: id, Name: strings.TrimSpace(in.Name), Type: in.Type, Enabled: in.Enabled, Events: normalizeEvents(in.Events), Configured: true}, nil
|
||||
}
|
||||
func (s *Service) List(ctx context.Context) ([]Channel, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,name,type,enabled,event_filter_json,length(encrypted_config)>0 FROM notification_channels ORDER BY name COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Channel
|
||||
for rows.Next() {
|
||||
var c Channel
|
||||
var body string
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Enabled, &body, &c.Configured); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(body), &c.Events)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
func (s *Service) Delete(ctx context.Context, id string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM notification_channels WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
func (s *Service) Queue(ctx context.Context, event Event) error {
|
||||
if !validEvent(event.Type) || len(event.Message) > 1000 {
|
||||
return errors.New("invalid notification event")
|
||||
}
|
||||
payload, _ := json.Marshal(event)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,event_filter_json FROM notification_channels WHERE enabled=1`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var channelIDs []string
|
||||
for rows.Next() {
|
||||
var id, filter string
|
||||
if err := rows.Scan(&id, &filter); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
var events []string
|
||||
_ = json.Unmarshal([]byte(filter), &events)
|
||||
if matches(events, event.Type) {
|
||||
channelIDs = append(channelIDs, id)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, id := range channelIDs {
|
||||
if _, err := s.db.ExecContext(ctx, `INSERT INTO notification_deliveries(id,channel_id,event_type,payload_redacted,next_attempt_at,created_at) VALUES(?,?,?,?,?,?)`, randomID(), id, event.Type, string(payload), now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) Test(ctx context.Context, id string) error {
|
||||
return s.queueForChannel(ctx, id, Event{Type: "notification.test", Title: "DoGaMa test notification", Message: "This is a test notification from DoGaMa."})
|
||||
}
|
||||
func (s *Service) queueForChannel(ctx context.Context, id string, event Event) error {
|
||||
var enabled bool
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT enabled FROM notification_channels WHERE id=?`, id).Scan(&enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, _ := json.Marshal(event)
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO notification_deliveries(id,channel_id,event_type,payload_redacted,next_attempt_at,created_at) VALUES(?,?,?,?,?,?)`, randomID(), id, event.Type, string(payload), now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) RunDue(ctx context.Context) error {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT d.id,d.attempt,d.payload_redacted,c.type,c.encrypted_config FROM notification_deliveries d JOIN notification_channels c ON c.id=d.channel_id WHERE d.status IN ('queued','retrying') AND d.next_attempt_at<=? ORDER BY d.next_attempt_at LIMIT 20`, s.now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type job struct {
|
||||
id, typ, payload string
|
||||
attempt int
|
||||
encrypted []byte
|
||||
}
|
||||
var jobs []job
|
||||
for rows.Next() {
|
||||
var j job
|
||||
if err := rows.Scan(&j.id, &j.attempt, &j.payload, &j.typ, &j.encrypted); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
rows.Close()
|
||||
for _, j := range jobs {
|
||||
config, e := s.open(j.encrypted)
|
||||
if e == nil {
|
||||
e = s.deliver(ctx, j.id, j.typ, config, []byte(j.payload))
|
||||
}
|
||||
attempt := j.attempt + 1
|
||||
if e == nil {
|
||||
if _, updateErr := s.db.ExecContext(ctx, `UPDATE notification_deliveries SET status='succeeded',attempt=?,completed_at=?,last_error_code='' WHERE id=?`, attempt, s.now().UTC().Format(time.RFC3339Nano), j.id); updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
} else {
|
||||
status := "retrying"
|
||||
if attempt >= 5 {
|
||||
status = "failed"
|
||||
}
|
||||
delay := time.Duration(1<<min(attempt, 6)) * time.Minute
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE notification_deliveries SET status=?,attempt=?,next_attempt_at=?,last_error_code=? WHERE id=?`, status, attempt, s.now().UTC().Add(delay).Format(time.RFC3339Nano), errorCode(e), j.id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) deliver(ctx context.Context, id, typ string, c map[string]string, payload []byte) error {
|
||||
if typ == "email" {
|
||||
host := c["host"]
|
||||
port := c["port"]
|
||||
if port == "" {
|
||||
port = "587"
|
||||
}
|
||||
addr := net.JoinHostPort(host, port)
|
||||
var auth smtp.Auth
|
||||
if c["username"] != "" {
|
||||
auth = smtp.PlainAuth("", c["username"], c["password"], host)
|
||||
}
|
||||
msg := []byte("To: " + c["to"] + "\r\nSubject: DoGaMa notification\r\nContent-Type: application/json\r\n\r\n" + string(payload))
|
||||
return sendSMTP(ctx, addr, host, auth, c["from"], strings.Split(c["to"], ","), msg)
|
||||
}
|
||||
u, err := url.Parse(c["url"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.validateURL(ctx, u); err != nil {
|
||||
return err
|
||||
}
|
||||
body := payload
|
||||
if typ == "discord" {
|
||||
var event Event
|
||||
_ = json.Unmarshal(payload, &event)
|
||||
body, _ = json.Marshal(map[string]string{"content": event.Title + "\n" + event.Message})
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-DoGaMa-Event-ID", id)
|
||||
timestamp := strconv.FormatInt(s.now().Unix(), 10)
|
||||
req.Header.Set("X-DoGaMa-Timestamp", timestamp)
|
||||
if secret := c["signing_secret"]; secret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(timestamp + "." + string(body)))
|
||||
req.Header.Set("X-DoGaMa-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("remote_status_%d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) validateURL(ctx context.Context, u *url.URL) error {
|
||||
if u.Scheme != "https" || u.User != nil || u.Hostname() == "" {
|
||||
return errors.New("unsafe_destination")
|
||||
}
|
||||
ips, err := s.resolver.LookupNetIP(ctx, "ip", u.Hostname())
|
||||
if err != nil {
|
||||
return errors.New("destination_resolution_failed")
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if unsafeIP(ip) {
|
||||
return errors.New("unsafe_destination")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) dialSafe(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, errors.New("unsafe_destination")
|
||||
}
|
||||
ips, err := s.resolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return nil, errors.New("destination_resolution_failed")
|
||||
}
|
||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||
for _, ip := range ips {
|
||||
if unsafeIP(ip) {
|
||||
return nil, errors.New("unsafe_destination")
|
||||
}
|
||||
connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if dialErr == nil {
|
||||
return connection, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("delivery_failed")
|
||||
}
|
||||
|
||||
func sendSMTP(ctx context.Context, address, host string, auth smtp.Auth, from string, recipients []string, message []byte) error {
|
||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||
connection, err := dialer.DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := smtp.NewClient(connection, host)
|
||||
if err != nil {
|
||||
_ = connection.Close()
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
if ok, _ := client.Extension("STARTTLS"); !ok {
|
||||
return errors.New("smtp_tls_required")
|
||||
}
|
||||
if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
return err
|
||||
}
|
||||
if auth != nil {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, recipient := range recipients {
|
||||
if err := client.Rcpt(strings.TrimSpace(recipient)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = w.Write(message); err != nil {
|
||||
_ = w.Close()
|
||||
return err
|
||||
}
|
||||
if err = w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
func unsafeIP(ip netip.Addr) bool {
|
||||
return !ip.IsValid() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified()
|
||||
}
|
||||
func (s *Service) seal(config map[string]string) ([]byte, error) {
|
||||
body, _ := json.Marshal(config)
|
||||
nonce := make([]byte, s.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.aead.Seal(nonce, nonce, body, nil), nil
|
||||
}
|
||||
func (s *Service) open(body []byte) (map[string]string, error) {
|
||||
n := s.aead.NonceSize()
|
||||
if len(body) < n {
|
||||
return nil, errors.New("invalid encrypted channel")
|
||||
}
|
||||
plain, err := s.aead.Open(nil, body[:n], body[n:], nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out map[string]string
|
||||
err = json.Unmarshal(plain, &out)
|
||||
return out, err
|
||||
}
|
||||
func validType(v string) bool { return v == "email" || v == "webhook" || v == "discord" }
|
||||
func validEvent(v string) bool {
|
||||
return v == "notification.test" || strings.HasSuffix(v, ".failed") || strings.HasSuffix(v, ".completed") || strings.HasSuffix(v, ".required")
|
||||
}
|
||||
func validateConfigShape(typ string, c map[string]string) error {
|
||||
if typ == "email" {
|
||||
if c["host"] == "" || c["from"] == "" || c["to"] == "" {
|
||||
return errors.New("email host, from and to are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(c["url"])
|
||||
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil {
|
||||
return errors.New("an HTTPS webhook URL is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func normalizeEvents(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, v := range in {
|
||||
v = strings.TrimSpace(v)
|
||||
if validEvent(v) && !seen[v] {
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func matches(filter []string, event string) bool {
|
||||
for _, v := range filter {
|
||||
if v == event || v == "*" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func errorCode(err error) string {
|
||||
v := err.Error()
|
||||
if strings.HasPrefix(v, "remote_status_") {
|
||||
return v
|
||||
}
|
||||
switch v {
|
||||
case "unsafe_destination", "destination_resolution_failed":
|
||||
return v
|
||||
}
|
||||
return "delivery_failed"
|
||||
}
|
||||
func randomID() string {
|
||||
b := make([]byte, 18)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package notification_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
)
|
||||
|
||||
func TestChannelSecretsAreEncryptedAndWriteOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
service, err := notification.New(db, bytes.Repeat([]byte{7}, 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channel, err := service.Upsert(ctx, "", notification.Input{Name: "ops", Type: "webhook", Enabled: true, Events: []string{"backup.failed"}, Config: map[string]string{"url": "https://example.com/hook", "signing_secret": "highly-sensitive"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var encrypted []byte
|
||||
if err := db.QueryRowContext(ctx, `SELECT encrypted_config FROM notification_channels WHERE id=?`, channel.ID).Scan(&encrypted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encrypted), "highly-sensitive") {
|
||||
t.Fatal("secret stored in plaintext")
|
||||
}
|
||||
channels, err := service.List(ctx)
|
||||
if err != nil || len(channels) != 1 || !channels[0].Configured {
|
||||
t.Fatalf("channels=%#v err=%v", channels, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryBlocksPrivateWebhookAndRetriesWithRedactedError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
service, _ := notification.New(db, bytes.Repeat([]byte{8}, 32))
|
||||
channel, err := service.Upsert(ctx, "", notification.Input{Name: "unsafe", Type: "webhook", Enabled: true, Events: []string{"backup.failed"}, Config: map[string]string{"url": "https://127.0.0.1/hook", "signing_secret": "never-leak"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.Queue(ctx, notification.Event{Type: "backup.failed", Title: "Backup failed", Message: "Operation failed", OperationID: "op-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.RunDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var status, code string
|
||||
var attempt int
|
||||
if err := db.QueryRowContext(ctx, `SELECT status,attempt,last_error_code FROM notification_deliveries WHERE channel_id=?`, channel.ID).Scan(&status, &attempt, &code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "retrying" || attempt != 1 || code != "unsafe_destination" || strings.Contains(code, "never-leak") {
|
||||
t.Fatalf("status=%s attempt=%d code=%q", status, attempt, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsMissingKeyAndInsecureURL(t *testing.T) {
|
||||
if _, err := notification.New(nil, []byte("short")); err == nil {
|
||||
t.Fatal("short key accepted")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
service, _ := notification.New(db, bytes.Repeat([]byte{9}, 32))
|
||||
if _, err := service.Upsert(ctx, "", notification.Input{Name: "bad", Type: "discord", Enabled: true, Config: map[string]string{"url": "http://example.com"}}); err == nil {
|
||||
t.Fatal("insecure URL accepted")
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,10 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 8 {
|
||||
t.Fatalf("got %d migrations, want 8", count)
|
||||
if count != 9 {
|
||||
t.Fatalf("got %d migrations, want 9", count)
|
||||
}
|
||||
for _, table := range []string{"instance_memberships", "permission_overrides", "installation_requests", "backup_policies", "backups", "imports"} {
|
||||
for _, table := range []string{"instance_memberships", "permission_overrides", "installation_requests", "backup_policies", "backups", "imports", "notification_channels", "notification_deliveries", "audit_events"} {
|
||||
var found int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&found); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -62,8 +62,8 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 8 {
|
||||
t.Fatalf("reopened database has %d migrations, want 8", count)
|
||||
if count != 9 {
|
||||
t.Fatalf("reopened database has %d migrations, want 9", count)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
|
||||
)
|
||||
|
||||
func (s *server) requireRecentAdmin(w http.ResponseWriter, r *http.Request, api bool) (auth.User, bool) {
|
||||
var user auth.User
|
||||
var ok bool
|
||||
if api {
|
||||
user, ok = s.requireAPIUser(w, r, true)
|
||||
} else {
|
||||
var err error
|
||||
user, err = s.currentUser(r)
|
||||
ok = err == nil && user.Role == "admin"
|
||||
if ok {
|
||||
session, e := r.Cookie(sessionCookie)
|
||||
ok = e == nil && s.parseForm(w, r) && s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token"))
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return auth.User{}, false
|
||||
}
|
||||
if time.Since(user.AuthenticatedAt) > 10*time.Minute {
|
||||
if api {
|
||||
s.apiProblem(w, http.StatusForbidden, "reauthentication_required", "Recent authentication is required.")
|
||||
} else {
|
||||
s.problem(w, http.StatusForbidden, "Recent authentication is required.")
|
||||
}
|
||||
return auth.User{}, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
func (s *server) recordAudit(r *http.Request, actor auth.User, action, outcome string, summary map[string]string) {
|
||||
if s.audit == nil {
|
||||
return
|
||||
}
|
||||
if err := s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, Action: action, Outcome: outcome, Summary: summary}); err != nil {
|
||||
s.logger.Warn("audit recording failed", "event", "audit.record.failed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) auditList(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAPIUser(w, r, true); !ok {
|
||||
return
|
||||
}
|
||||
f := audit.Filter{ActorID: r.URL.Query().Get("actor_id"), InstanceID: r.URL.Query().Get("instance_id"), Action: r.URL.Query().Get("action"), Outcome: r.URL.Query().Get("outcome")}
|
||||
events, err := s.audit.List(r.Context(), f)
|
||||
if err != nil {
|
||||
s.apiProblem(w, 500, "audit_unavailable", "Audit events are unavailable.")
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, 200, map[string]any{"events": events})
|
||||
}
|
||||
func (s *server) auditPolicyGet(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAPIUser(w, r, true); !ok {
|
||||
return
|
||||
}
|
||||
p, err := s.audit.Policy(r.Context())
|
||||
if err != nil {
|
||||
s.apiProblem(w, 500, "audit_unavailable", "Audit policy is unavailable.")
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, 200, p)
|
||||
}
|
||||
func (s *server) auditPolicyPut(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var p audit.Policy
|
||||
if !s.decodeAPIJSON(w, r, &p) {
|
||||
return
|
||||
}
|
||||
if err := s.audit.SetPolicy(r.Context(), p); err != nil {
|
||||
s.recordAudit(r, actor, "audit.policy.update", "failed", map[string]string{"reason_code": "invalid_policy"})
|
||||
s.apiProblem(w, 422, "invalid_policy", "The audit policy is invalid.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "audit.policy.update", "allowed", nil)
|
||||
s.apiJSON(w, 200, p)
|
||||
}
|
||||
func (s *server) auditPurge(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Before string `json:"before"`
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
if !s.decodeAPIJSON(w, r, &in) || !in.Confirm {
|
||||
s.apiProblem(w, 422, "confirmation_required", "Purge confirmation is required.")
|
||||
return
|
||||
}
|
||||
before, err := time.Parse(time.RFC3339, in.Before)
|
||||
if err != nil {
|
||||
s.apiProblem(w, 422, "invalid_boundary", "The purge boundary is invalid.")
|
||||
return
|
||||
}
|
||||
n, err := s.audit.Purge(r.Context(), before)
|
||||
if err != nil {
|
||||
s.apiProblem(w, 422, "purge_failed", "The audit purge failed.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "audit.purge", "allowed", map[string]string{"deleted_count": strconv.FormatInt(n, 10)})
|
||||
s.apiJSON(w, 200, map[string]int64{"deleted_count": n})
|
||||
}
|
||||
|
||||
func (s *server) notificationList(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAPIUser(w, r, true); !ok {
|
||||
return
|
||||
}
|
||||
values, err := s.notifications.List(r.Context())
|
||||
if err != nil {
|
||||
s.apiProblem(w, 500, "channels_unavailable", "Notification channels are unavailable.")
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, 200, map[string]any{"channels": values})
|
||||
}
|
||||
func (s *server) notificationCreate(w http.ResponseWriter, r *http.Request) {
|
||||
s.notificationUpsert(w, r, "")
|
||||
}
|
||||
func (s *server) notificationUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
s.notificationUpsert(w, r, r.PathValue("id"))
|
||||
}
|
||||
func (s *server) notificationUpsert(w http.ResponseWriter, r *http.Request, id string) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in notification.Input
|
||||
if !s.decodeAPIJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
value, err := s.notifications.Upsert(r.Context(), id, in)
|
||||
if err != nil {
|
||||
s.recordAudit(r, actor, "notification.channel.update", "failed", map[string]string{"channel_type": in.Type, "reason_code": "invalid_channel"})
|
||||
s.apiProblem(w, 422, "invalid_channel", "The notification channel is invalid.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.update", "allowed", map[string]string{"target_id": value.ID, "channel_type": value.Type})
|
||||
status := http.StatusOK
|
||||
if id == "" {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
s.apiJSON(w, status, value)
|
||||
}
|
||||
func (s *server) notificationDelete(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.notifications.Delete(r.Context(), r.PathValue("id")); err != nil {
|
||||
s.apiProblem(w, 500, "delete_failed", "The channel could not be deleted.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.delete", "allowed", map[string]string{"target_id": r.PathValue("id")})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
func (s *server) notificationTest(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !s.requireEmptyBody(w, r) {
|
||||
return
|
||||
}
|
||||
if err := s.notifications.Test(r.Context(), r.PathValue("id")); err != nil {
|
||||
s.apiProblem(w, 404, "channel_not_found", "The channel was not found.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.test", "allowed", map[string]string{"target_id": r.PathValue("id"), "event_type": "notification.test"})
|
||||
s.apiJSON(w, 202, map[string]string{"status": "queued"})
|
||||
}
|
||||
|
||||
func (s *server) notificationForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
config := map[string]string{"url": r.FormValue("url"), "signing_secret": r.FormValue("signing_secret"), "host": r.FormValue("host"), "port": r.FormValue("port"), "username": r.FormValue("smtp_username"), "password": r.FormValue("smtp_password"), "from": r.FormValue("from"), "to": r.FormValue("to")}
|
||||
value, err := s.notifications.Upsert(r.Context(), "", notification.Input{Name: r.FormValue("name"), Type: r.FormValue("type"), Enabled: true, Events: strings.Fields(r.FormValue("events")), Config: config})
|
||||
if err != nil {
|
||||
s.problem(w, 422, "Invalid notification channel.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.update", "allowed", map[string]string{"target_id": value.ID, "channel_type": value.Type})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) notificationTestForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.notifications.Test(r.Context(), r.PathValue("id")); err != nil {
|
||||
s.problem(w, 404, "Notification channel not found.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.test", "allowed", map[string]string{"target_id": r.PathValue("id"), "event_type": "notification.test"})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) notificationDeleteForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.notifications.Delete(r.Context(), r.PathValue("id")); err != nil {
|
||||
s.problem(w, 500, "Notification channel could not be deleted.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "notification.channel.delete", "allowed", map[string]string{"target_id": r.PathValue("id")})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) auditPolicyForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
days, _ := strconv.Atoi(r.FormValue("retention_days"))
|
||||
maximum, _ := strconv.Atoi(r.FormValue("maximum_count"))
|
||||
if err := s.audit.SetPolicy(r.Context(), audit.Policy{RetentionDays: days, MaximumCount: maximum}); err != nil {
|
||||
s.problem(w, 422, "Invalid audit policy.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "audit.policy.update", "allowed", nil)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
func (s *server) auditPurgeForm(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireRecentAdmin(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.FormValue("confirm") != "yes" {
|
||||
s.problem(w, 422, "Purge confirmation is required.")
|
||||
return
|
||||
}
|
||||
before, err := time.Parse("2006-01-02", r.FormValue("before"))
|
||||
if err != nil {
|
||||
s.problem(w, 422, "Invalid purge date.")
|
||||
return
|
||||
}
|
||||
n, err := s.audit.Purge(r.Context(), before)
|
||||
if err != nil {
|
||||
s.problem(w, 422, "Audit purge failed.")
|
||||
return
|
||||
}
|
||||
s.recordAudit(r, actor, "audit.purge", "allowed", map[string]string{"deleted_count": strconv.FormatInt(n, 10)})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
+183
-16
@@ -16,15 +16,18 @@ import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -58,14 +61,16 @@ var englishMessages = map[string]string{
|
||||
}
|
||||
|
||||
type server struct {
|
||||
auth *auth.Service
|
||||
templates *template.Template
|
||||
logger *slog.Logger
|
||||
repository repository
|
||||
lifecycle *instance.LifecycleService
|
||||
permissions *authorization.Service
|
||||
backups *backup.Service
|
||||
imports *importexport.Service
|
||||
auth *auth.Service
|
||||
templates *template.Template
|
||||
logger *slog.Logger
|
||||
repository repository
|
||||
lifecycle *instance.LifecycleService
|
||||
permissions *authorization.Service
|
||||
backups *backup.Service
|
||||
imports *importexport.Service
|
||||
audit *audit.Service
|
||||
notifications *notification.Service
|
||||
}
|
||||
|
||||
type repository interface {
|
||||
@@ -76,12 +81,19 @@ type repository interface {
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Title string
|
||||
CSRFToken string
|
||||
Error string
|
||||
User auth.User
|
||||
GlobalLabels string
|
||||
IsAdmin bool
|
||||
Title string
|
||||
CSRFToken string
|
||||
Error string
|
||||
User auth.User
|
||||
GlobalLabels string
|
||||
IsAdmin bool
|
||||
Channels []notification.Channel
|
||||
AuditEvents []audit.Event
|
||||
AuditPolicy audit.Policy
|
||||
AuditActor string
|
||||
AuditInstance string
|
||||
AuditAction string
|
||||
AuditOutcome string
|
||||
}
|
||||
|
||||
// NewHandler constructs the complete HTTP application.
|
||||
@@ -109,16 +121,25 @@ func NewHandlerWithLifecycleAndBackup(authService *auth.Service, repository repo
|
||||
return newHandlerWithImports(authService, repository, instance.NewLifecycleService(repository, agent), backupService, importService, logger)
|
||||
}
|
||||
|
||||
// NewHandlerComplete enables the milestone-nine administration services.
|
||||
func NewHandlerComplete(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, logger *slog.Logger) (http.Handler, error) {
|
||||
return newHandlerServices(authService, repository, lifecycle, backupService, importService, auditService, notificationService, logger)
|
||||
}
|
||||
|
||||
func newHandler(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, logger *slog.Logger) (http.Handler, error) {
|
||||
return newHandlerWithImports(authService, repository, lifecycle, backupService, nil, logger)
|
||||
}
|
||||
|
||||
func newHandlerWithImports(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, logger *slog.Logger) (http.Handler, error) {
|
||||
return newHandlerServices(authService, repository, lifecycle, backupService, importService, nil, nil, logger)
|
||||
}
|
||||
|
||||
func newHandlerServices(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, logger *slog.Logger) (http.Handler, error) {
|
||||
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message}).ParseFS(assets, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle, backups: backupService, imports: importService}
|
||||
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle, backups: backupService, imports: importService, audit: auditService, notifications: notificationService}
|
||||
if repository != nil {
|
||||
s.permissions = authorization.New(repository)
|
||||
}
|
||||
@@ -135,6 +156,19 @@ func newHandlerWithImports(authService *auth.Service, repository repository, lif
|
||||
mux.HandleFunc("POST /api/v1/admin/users", s.userCreate)
|
||||
mux.HandleFunc("GET /api/v1/admin/game-container-labels", s.globalLabelsGet)
|
||||
mux.HandleFunc("PUT /api/v1/admin/game-container-labels", s.globalLabelsPut)
|
||||
if auditService != nil {
|
||||
mux.HandleFunc("GET /api/v1/admin/audit", s.auditList)
|
||||
mux.HandleFunc("GET /api/v1/admin/audit-policy", s.auditPolicyGet)
|
||||
mux.HandleFunc("PUT /api/v1/admin/audit-policy", s.auditPolicyPut)
|
||||
mux.HandleFunc("POST /api/v1/admin/audit/purge", s.auditPurge)
|
||||
}
|
||||
if notificationService != nil {
|
||||
mux.HandleFunc("GET /api/v1/admin/notification-channels", s.notificationList)
|
||||
mux.HandleFunc("POST /api/v1/admin/notification-channels", s.notificationCreate)
|
||||
mux.HandleFunc("PUT /api/v1/admin/notification-channels/{id}", s.notificationUpdate)
|
||||
mux.HandleFunc("DELETE /api/v1/admin/notification-channels/{id}", s.notificationDelete)
|
||||
mux.HandleFunc("POST /api/v1/admin/notification-channels/{id}/test", s.notificationTest)
|
||||
}
|
||||
mux.HandleFunc("GET /api/v1/instances/{id}/memberships", s.membershipList)
|
||||
mux.HandleFunc("PUT /api/v1/instances/{id}/memberships/{userID}", s.membershipSet)
|
||||
mux.HandleFunc("DELETE /api/v1/instances/{id}/memberships/{userID}", s.membershipDelete)
|
||||
@@ -176,8 +210,107 @@ func newHandlerWithImports(authService *auth.Service, repository repository, lif
|
||||
mux.HandleFunc("POST /login", s.loginSubmit)
|
||||
mux.HandleFunc("POST /logout", s.logout)
|
||||
mux.HandleFunc("POST /admin/game-container-labels", s.globalLabelsForm)
|
||||
mux.HandleFunc("POST /admin/notification-channels", s.notificationForm)
|
||||
mux.HandleFunc("POST /admin/notification-channels/{id}/test", s.notificationTestForm)
|
||||
mux.HandleFunc("POST /admin/notification-channels/{id}/delete", s.notificationDeleteForm)
|
||||
mux.HandleFunc("POST /admin/audit-policy", s.auditPolicyForm)
|
||||
mux.HandleFunc("POST /admin/audit-purge", s.auditPurgeForm)
|
||||
mux.HandleFunc("GET /", s.home)
|
||||
return s.securityHeaders(mux), nil
|
||||
return s.securityHeaders(s.auditRequests(mux)), nil
|
||||
}
|
||||
|
||||
type auditResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *auditResponseWriter) WriteHeader(status int) {
|
||||
if w.status == 0 {
|
||||
w.status = status
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
func (w *auditResponseWriter) Write(body []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
}
|
||||
return w.ResponseWriter.Write(body)
|
||||
}
|
||||
|
||||
func (s *server) auditRequests(next http.Handler) http.Handler {
|
||||
if s.audit == nil {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
action := auditAction(r.Method, r.URL.Path)
|
||||
if action == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
actor, _ := s.currentUser(r)
|
||||
wrapped := &auditResponseWriter{ResponseWriter: w}
|
||||
next.ServeHTTP(wrapped, r)
|
||||
status := wrapped.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
outcome := "allowed"
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
outcome = "denied"
|
||||
} else if status >= 400 {
|
||||
outcome = "failed"
|
||||
}
|
||||
summary := map[string]string{}
|
||||
if id := r.PathValue("id"); id != "" {
|
||||
summary["target_id"] = id
|
||||
}
|
||||
instanceID := ""
|
||||
if status < 400 && strings.HasPrefix(r.URL.Path, "/api/v1/instances/") {
|
||||
instanceID = r.PathValue("id")
|
||||
}
|
||||
_ = s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, InstanceID: instanceID, Action: action, Outcome: outcome, Summary: summary})
|
||||
})
|
||||
}
|
||||
|
||||
func auditAction(method, path string) string {
|
||||
if method == http.MethodGet || strings.HasPrefix(path, "/api/v1/admin/audit") || strings.HasPrefix(path, "/api/v1/admin/notification") || strings.HasPrefix(path, "/admin/audit") || strings.HasPrefix(path, "/admin/notification") {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case path == "/api/v1/admin/users":
|
||||
return "user.create"
|
||||
case strings.Contains(path, "/memberships/") && strings.Contains(path, "/permissions/"):
|
||||
return "permission.override.change"
|
||||
case strings.Contains(path, "/memberships/"):
|
||||
return "membership.change"
|
||||
case strings.Contains(path, "installation-requests") && strings.HasSuffix(path, "/review"):
|
||||
return "installation_request.review"
|
||||
case strings.Contains(path, "/backups/") && strings.HasSuffix(path, "/restore"):
|
||||
return "backup.restore"
|
||||
case strings.HasSuffix(path, "/backups"):
|
||||
return "backup.create"
|
||||
case path == "/api/v1/imports":
|
||||
return "import.create"
|
||||
case strings.HasSuffix(path, "/update"):
|
||||
return "instance.update"
|
||||
case strings.Contains(path, "configuration-revisions") && strings.HasSuffix(path, "/rollback"):
|
||||
return "configuration.rollback"
|
||||
case strings.HasSuffix(path, "/start"):
|
||||
return "instance.start"
|
||||
case strings.HasSuffix(path, "/stop"):
|
||||
return "instance.stop"
|
||||
case strings.HasSuffix(path, "/restart"):
|
||||
return "instance.restart"
|
||||
case strings.HasSuffix(path, "/install"):
|
||||
return "instance.create"
|
||||
case strings.HasPrefix(path, "/api/v1/instances/") && method == http.MethodDelete:
|
||||
return "instance.delete"
|
||||
case strings.Contains(path, "container-configuration") || strings.HasSuffix(path, "/mods"):
|
||||
return "instance.configuration.change"
|
||||
case strings.Contains(path, "game-container-labels"):
|
||||
return "security.configuration.change"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var publicGameIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
@@ -498,9 +631,11 @@ func (s *server) instanceUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
result, err := s.lifecycle.Update(r.Context(), current.ID, request, actor.ID)
|
||||
if err != nil {
|
||||
s.queueNotification(r, notification.Event{Type: "update.failed", Title: "Update failed", Message: "The instance update failed.", InstanceName: current.Preview.DisplayName})
|
||||
s.apiProblem(w, 422, "update_failed", err.Error())
|
||||
return
|
||||
}
|
||||
s.queueNotification(r, notification.Event{Type: "update.completed", Title: "Update completed", Message: "The instance update completed.", InstanceName: current.Preview.DisplayName})
|
||||
s.apiJSON(w, 200, result)
|
||||
}
|
||||
|
||||
@@ -768,9 +903,11 @@ func (s *server) backupCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
value, err := s.backups.Create(r.Context(), actor.ID, r.PathValue("id"), "manual")
|
||||
if err != nil {
|
||||
s.queueNotification(r, notification.Event{Type: "backup.failed", Title: "Backup failed", Message: "The manual backup failed."})
|
||||
s.backupProblem(w, err)
|
||||
return
|
||||
}
|
||||
s.queueNotification(r, notification.Event{Type: "backup.completed", Title: "Backup completed", Message: "The manual backup completed.", OperationID: value.ID})
|
||||
s.apiJSON(w, http.StatusCreated, value)
|
||||
}
|
||||
|
||||
@@ -796,9 +933,11 @@ func (s *server) backupRestore(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.backups.Restore(r.Context(), actor.ID, r.PathValue("id"), r.PathValue("backupID")); err != nil {
|
||||
s.queueNotification(r, notification.Event{Type: "restore.failed", Title: "Restore failed", Message: "The backup restore failed."})
|
||||
s.backupProblem(w, err)
|
||||
return
|
||||
}
|
||||
s.queueNotification(r, notification.Event{Type: "restore.completed", Title: "Restore completed", Message: "The backup restore completed."})
|
||||
s.apiJSON(w, http.StatusOK, map[string]string{"state": "restored"})
|
||||
}
|
||||
|
||||
@@ -1033,6 +1172,7 @@ func (s *server) installationRequestCreate(w http.ResponseWriter, r *http.Reques
|
||||
s.authorizationProblem(w, err)
|
||||
return
|
||||
}
|
||||
s.queueNotification(r, notification.Event{Type: "installation_request.required", Title: "Installation request submitted", Message: "An installation request requires administrator review."})
|
||||
s.apiJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
@@ -1066,9 +1206,19 @@ func (s *server) installationRequestReview(w http.ResponseWriter, r *http.Reques
|
||||
s.authorizationProblem(w, err)
|
||||
return
|
||||
}
|
||||
s.queueNotification(r, notification.Event{Type: "installation_request.completed", Title: "Installation request reviewed", Message: "An installation request was reviewed."})
|
||||
s.apiJSON(w, http.StatusOK, reviewed)
|
||||
}
|
||||
|
||||
func (s *server) queueNotification(r *http.Request, event notification.Event) {
|
||||
if s.notifications == nil {
|
||||
return
|
||||
}
|
||||
if err := s.notifications.Queue(r.Context(), event); err != nil {
|
||||
s.logger.Warn("notification queue failed", "event", "notification.queue.failed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) decodeAPIJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
@@ -1198,11 +1348,19 @@ func (s *server) loginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if errors.Is(err, auth.ErrRateLimited) {
|
||||
status = http.StatusTooManyRequests
|
||||
w.Header().Set("Retry-After", "60")
|
||||
if s.audit != nil {
|
||||
_ = s.audit.Record(r.Context(), audit.Event{ActorLabel: "anonymous", Action: "auth.login.blocked", Outcome: "denied", Summary: map[string]string{"reason_code": "rate_limited"}})
|
||||
}
|
||||
}
|
||||
token := s.anonymousCSRF(w, r)
|
||||
s.render(w, status, "login.html", pageData{Title: message("login.title"), CSRFToken: token, Error: message("error.credentials")})
|
||||
return
|
||||
}
|
||||
if s.audit != nil {
|
||||
if actor, actorErr := s.auth.Authenticate(r.Context(), session.Token); actorErr == nil {
|
||||
_ = s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, Action: "auth.login", Outcome: "allowed"})
|
||||
}
|
||||
}
|
||||
if cookie, cookieErr := r.Cookie(sessionCookie); cookieErr == nil {
|
||||
if revokeErr := s.auth.Revoke(r.Context(), cookie.Value); revokeErr != nil {
|
||||
_ = s.auth.Revoke(r.Context(), session.Token)
|
||||
@@ -1260,6 +1418,15 @@ func (s *server) home(w http.ResponseWriter, r *http.Request) {
|
||||
data.GlobalLabels = instance.FormatLabels(labels)
|
||||
}
|
||||
}
|
||||
if s.notifications != nil {
|
||||
data.Channels, _ = s.notifications.List(r.Context())
|
||||
}
|
||||
if s.audit != nil {
|
||||
data.AuditActor, data.AuditInstance = r.URL.Query().Get("actor_id"), r.URL.Query().Get("instance_id")
|
||||
data.AuditAction, data.AuditOutcome = r.URL.Query().Get("action"), r.URL.Query().Get("outcome")
|
||||
data.AuditEvents, _ = s.audit.List(r.Context(), audit.Filter{ActorID: data.AuditActor, InstanceID: data.AuditInstance, Action: data.AuditAction, Outcome: data.AuditOutcome, Limit: 50})
|
||||
data.AuditPolicy, _ = s.audit.Policy(r.Context())
|
||||
}
|
||||
}
|
||||
s.render(w, http.StatusOK, "home.html", data)
|
||||
}
|
||||
|
||||
@@ -17,11 +17,13 @@ import (
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
)
|
||||
|
||||
@@ -134,6 +136,55 @@ func TestCatalogPreviewAndDraftAPIAuthorization(t *testing.T) {
|
||||
assertStatus(t, unsafeResponse, http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
func TestNotificationAndAuditAdministration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
repository := sqlite.NewRepository(db)
|
||||
authService := auth.New(db)
|
||||
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auditService := audit.New(db)
|
||||
notificationService, err := notification.New(db, bytes.Repeat([]byte{3}, 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err := NewHandlerComplete(authService, repository, nil, nil, nil, auditService, notificationService, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookie := &http.Cookie{Name: sessionCookie, Value: session.Token}
|
||||
payload, _ := json.Marshal(map[string]any{"name": "operations", "type": "webhook", "enabled": true, "events": []string{"backup.failed"}, "config": map[string]string{"url": "https://example.com/hook", "signing_secret": "do-not-return"}})
|
||||
created := jsonMethodRequest(t, handler, http.MethodPost, "/api/v1/admin/notification-channels", payload, cookie, session.CSRFToken)
|
||||
assertStatus(t, created, http.StatusCreated)
|
||||
if strings.Contains(created.Body.String(), "do-not-return") {
|
||||
t.Fatal("channel secret returned")
|
||||
}
|
||||
listed := request(t, handler, http.MethodGet, "/api/v1/admin/notification-channels", []*http.Cookie{cookie})
|
||||
assertStatus(t, listed, http.StatusOK)
|
||||
if strings.Contains(listed.Body.String(), "do-not-return") || !strings.Contains(listed.Body.String(), "operations") {
|
||||
t.Fatalf("unsafe channel response: %s", listed.Body.String())
|
||||
}
|
||||
auditResponse := request(t, handler, http.MethodGet, "/api/v1/admin/audit", []*http.Cookie{cookie})
|
||||
assertStatus(t, auditResponse, http.StatusOK)
|
||||
if !strings.Contains(auditResponse.Body.String(), "notification.channel.update") {
|
||||
t.Fatalf("missing audit event: %s", auditResponse.Body.String())
|
||||
}
|
||||
home := request(t, handler, http.MethodGet, "/", []*http.Cookie{cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken}})
|
||||
assertStatus(t, home, http.StatusOK)
|
||||
if !strings.Contains(home.Body.String(), "Notification channels") || !strings.Contains(home.Body.String(), "Recent audit events") {
|
||||
t.Fatal("administration UI sections missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupAPIEnforcesPermissionsAndRestores(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
:root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.5; }
|
||||
body { margin: 0; background: #eef2f7; color: #172033; }
|
||||
main { width: min(32rem, calc(100% - 2rem)); margin: 8vh auto; padding: 2rem; background: white; border-radius: .75rem; box-shadow: 0 .5rem 2rem #17203318; }
|
||||
main { width: min(64rem, calc(100% - 2rem)); margin: 4vh auto; padding: 2rem; background: white; border-radius: .75rem; box-shadow: 0 .5rem 2rem #17203318; }
|
||||
section { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid #d0d5dd; }
|
||||
header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 2rem; background: white; }
|
||||
form { display: grid; gap: 1rem; }
|
||||
header form { display: block; }
|
||||
.inline { display: inline; margin-left: .5rem; }
|
||||
.inline button { padding: .35rem .55rem; }
|
||||
label { display: grid; gap: .35rem; font-weight: 600; }
|
||||
input, textarea, button { box-sizing: border-box; padding: .7rem; font: inherit; border: 1px solid #8a94a6; border-radius: .35rem; }
|
||||
input, textarea, select, button { box-sizing: border-box; padding: .7rem; font: inherit; border: 1px solid #8a94a6; border-radius: .35rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
|
||||
.table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; } th, td { padding: .6rem; text-align: left; border-bottom: 1px solid #d0d5dd; white-space: nowrap; }
|
||||
.danger { background: #b42318; }
|
||||
.warning { padding: .75rem; border-left: .25rem solid #b54708; background: #fffaeb; color: #7a2e0e; }
|
||||
button { border: 0; background: #3157d5; color: white; font-weight: 700; cursor: pointer; }
|
||||
.error { padding: .75rem; border-left: .25rem solid #b42318; background: #fee4e2; color: #7a271a; }
|
||||
@media (prefers-color-scheme: dark) { body { background: #111827; color: #e5e7eb; } main, header { background: #1f2937; } input { background: #111827; color: #e5e7eb; } }
|
||||
@media (max-width: 40rem) { main { padding: 1rem; margin: 1rem; width: auto; } header { padding: 1rem; } .grid { grid-template-columns: 1fr; } }
|
||||
@media (prefers-color-scheme: dark) { body { background: #111827; color: #e5e7eb; } main, header { background: #1f2937; } input, textarea, select { background: #111827; color: #e5e7eb; } }
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
{{define "home.html"}}<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head>
|
||||
<body><header><strong>{{msg "brand"}}</strong><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">{{msg "logout.submit"}}</button></form></header><main><h1>{{msg "dashboard.title"}}</h1><p>{{msg "dashboard.signed_in"}} <strong>{{.User.Username}}</strong>.</p><p>{{msg "dashboard.ready"}}</p>{{if .IsAdmin}}<section><h2>Game-container labels</h2><form method="post" action="/admin/game-container-labels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Global labels<textarea name="labels" rows="8" placeholder="key=value">{{.GlobalLabels}}</textarea></label><p>One label per line. Available variables: <code>{{`{{game.name}}`}}</code>, <code>{{`{{game.id}}`}}</code>, <code>{{`{{game.icon_url}}`}}</code>, <code>{{`{{instance.name}}`}}</code>, <code>{{`{{instance.id}}`}}</code>, <code>{{`{{instance.slug}}`}}</code>, <code>{{`{{server.name}}`}}</code>.</p><label><input type="radio" name="apply" value="next_start" checked> Apply on next start</label><label><input type="radio" name="apply" value="immediate"> Apply immediately</label><aside class="warning"><strong>Immediate application stops and recreates affected containers.</strong> Connected players are disconnected immediately. Persistent data is preserved and each instance returns to its previous running or stopped state.</aside><label><input type="checkbox" name="confirm_disconnection" value="yes"> I understand the immediate-disconnection warning</label><button type="submit">Save game-container labels</button></form></section>{{end}}</main></body></html>{{end}}
|
||||
<body><header><strong>{{msg "brand"}}</strong><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">{{msg "logout.submit"}}</button></form></header>
|
||||
<main><h1>{{msg "dashboard.title"}}</h1><p>{{msg "dashboard.signed_in"}} <strong>{{.User.Username}}</strong>.</p><p>{{msg "dashboard.ready"}}</p>
|
||||
{{if .IsAdmin}}
|
||||
<section><h2>Notification channels</h2><p>Secrets are encrypted and never displayed after saving. Delivery is queued and retried without blocking operations.</p>
|
||||
<ul>{{range .Channels}}<li><strong>{{.Name}}</strong> — {{.Type}} · {{if .Enabled}}enabled{{else}}disabled{{end}}
|
||||
<form class="inline" method="post" action="/admin/notification-channels/{{.ID}}/test"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button type="submit">Send test</button></form>
|
||||
<form class="inline" method="post" action="/admin/notification-channels/{{.ID}}/delete"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button class="danger" type="submit">Delete</button></form></li>{{else}}<li>No channel configured.</li>{{end}}</ul>
|
||||
<form method="post" action="/admin/notification-channels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Name<input name="name" required></label><label>Type<select name="type"><option value="webhook">HTTPS webhook</option><option value="discord">Discord webhook</option><option value="email">SMTP email</option></select></label><label>HTTPS URL<input name="url" type="url" placeholder="https://…"></label><label>Signing secret<input name="signing_secret" type="password" autocomplete="new-password"></label><div class="grid"><label>SMTP host<input name="host"></label><label>Port<input name="port" inputmode="numeric" placeholder="587"></label><label>Username<input name="smtp_username"></label><label>Password<input name="smtp_password" type="password" autocomplete="new-password"></label><label>From<input name="from" type="email"></label><label>Recipients<input name="to"></label></div><label>Events (space separated)<input name="events" value="backup.failed update.failed restore.failed security.required"></label><button type="submit">Add channel</button></form></section>
|
||||
<section><h2>Audit retention</h2><form method="post" action="/admin/audit-policy"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><div class="grid"><label>Retention days<input name="retention_days" type="number" min="0" max="3650" value="{{.AuditPolicy.RetentionDays}}"></label><label>Maximum entries<input name="maximum_count" type="number" min="0" max="1000000" value="{{.AuditPolicy.MaximumCount}}"></label></div><p>Zero means unlimited and may grow the database indefinitely.</p><button type="submit">Save retention</button></form>
|
||||
<form method="post" action="/admin/audit-purge"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Delete events before<input name="before" type="date" required></label><label><input name="confirm" type="checkbox" value="yes"> Confirm bounded audit purge</label><button class="danger" type="submit">Purge audit events</button></form></section>
|
||||
<section><h2>Recent audit events</h2><form method="get" action="/"><div class="grid"><label>Actor ID<input name="actor_id" value="{{.AuditActor}}"></label><label>Instance ID<input name="instance_id" value="{{.AuditInstance}}"></label><label>Action<input name="action" value="{{.AuditAction}}"></label><label>Outcome<select name="outcome"><option value="">Any</option><option value="allowed">Allowed</option><option value="denied">Denied</option><option value="failed">Failed</option></select></label></div><button type="submit">Filter audit</button></form>
|
||||
<div class="table-wrap"><table><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Outcome</th><th>Instance</th></tr></thead><tbody>{{range .AuditEvents}}<tr><td>{{.OccurredAt.Format "2006-01-02 15:04:05Z"}}</td><td>{{.ActorLabel}}</td><td><code>{{.Action}}</code></td><td>{{.Outcome}}</td><td>{{.InstanceID}}</td></tr>{{else}}<tr><td colspan="5">No audit event.</td></tr>{{end}}</tbody></table></div></section>
|
||||
<section><h2>Game-container labels</h2><form method="post" action="/admin/game-container-labels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Global labels<textarea name="labels" rows="8" placeholder="key=value">{{.GlobalLabels}}</textarea></label><p>One label per line. Available variables: <code>{{`{{game.name}}`}}</code>, <code>{{`{{game.id}}`}}</code>, <code>{{`{{game.icon_url}}`}}</code>, <code>{{`{{instance.name}}`}}</code>, <code>{{`{{instance.id}}`}}</code>, <code>{{`{{instance.slug}}`}}</code>, <code>{{`{{server.name}}`}}</code>.</p><label><input type="radio" name="apply" value="next_start" checked> Apply on next start</label><label><input type="radio" name="apply" value="immediate"> Apply immediately</label><aside class="warning"><strong>Immediate application stops and recreates affected containers.</strong> Connected players are disconnected immediately. Persistent data is preserved and each instance returns to its previous running or stopped state.</aside><label><input type="checkbox" name="confirm_disconnection" value="yes"> I understand the immediate-disconnection warning</label><button type="submit">Save game-container labels</button></form></section>
|
||||
{{end}}</main></body></html>{{end}}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
CREATE TABLE notification_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('email', 'webhook', 'discord')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
encrypted_config BLOB NOT NULL,
|
||||
event_filter_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE notification_deliveries (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_redacted TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'retrying', 'succeeded', 'failed')),
|
||||
attempt INTEGER NOT NULL DEFAULT 0 CHECK (attempt >= 0),
|
||||
next_attempt_at TEXT NOT NULL,
|
||||
last_error_code TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
CREATE INDEX notification_deliveries_due_idx ON notification_deliveries(status, next_attempt_at);
|
||||
|
||||
CREATE TABLE audit_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
occurred_at TEXT NOT NULL,
|
||||
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
actor_label TEXT NOT NULL,
|
||||
instance_id TEXT REFERENCES instances(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL CHECK (outcome IN ('allowed', 'denied', 'failed')),
|
||||
summary_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX audit_events_time_idx ON audit_events(occurred_at DESC, id DESC);
|
||||
CREATE INDEX audit_events_filters_idx ON audit_events(actor_id, instance_id, action, outcome);
|
||||
|
||||
INSERT INTO system_settings(key, value_json, revision, updated_at)
|
||||
VALUES ('audit_policy', '{"retention_days":30,"maximum_count":10000}', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
Reference in New Issue
Block a user