// Package notifier handles alerts and approval requests via Matrix. // Uses the DB as the rendezvous — no service-to-service calls (SA7/A7). // Pending approvals survive restarts of either side. package notifier import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "log/slog" "time" "github.com/dtoro/oikos/internal/config" "github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/db/sqlcgen" "github.com/google/uuid" ) // Run starts the notifier loop. Blocks until ctx is cancelled. func Run(ctx context.Context, pool *db.Pool, cfg config.Config) { slog.Info("notifier: starting") interval := 15 * time.Second ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): slog.Info("notifier: shutting down") return case <-ticker.C: processPendingApprovals(ctx, pool, cfg) } } } // RunnerForMain provides the run function for registration in main. func RunnerForMain() func(context.Context, *db.Pool, config.Config) { return Run } // processPendingApprovals checks for pending approvals and sends alerts. func processPendingApprovals(ctx context.Context, pool *db.Pool, cfg config.Config) { q := sqlcgen.New(pool) status := "pending" approvals, err := q.ListApprovals(ctx, sqlcgen.ListApprovalsParams{ Status: &status, }) if err != nil { slog.Error("notifier: list approvals", "error", err) return } for _, a := range approvals { // Check if already expired if a.ExpiresAt.Before(time.Now()) { _ = q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{ EntityID: a.EntityID, Status: "expired", }) continue } // Generate approval token only if not already generated if a.TokenHash != nil && *a.TokenHash != "" { continue } token := generateApprovalToken(a.EntityID, cfg.ApprovalHMACSecret) tokenHash := hashToken(token) // Store token hash _, _ = pool.Exec(ctx, "UPDATE approvals SET token_hash = $2 WHERE entity_id = $1", a.EntityID, tokenHash) slog.Info("notifier: approval pending", "approval_id", a.EntityID, "action", a.Action, "risk_class", a.RiskClass, "token", token[:16]+"...", "expires_at", a.ExpiresAt) } } // generateApprovalToken creates a single-use HMAC token for an approval. // Token = HMAC(approval_id ‖ nonce, secret) func generateApprovalToken(approvalID uuid.UUID, secret string) string { if secret == "" { secret = "dev-secret-do-not-use-in-prod" } nonce := fmt.Sprintf("%d", time.Now().UnixNano()) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(approvalID.String())) mac.Write([]byte(nonce)) return hex.EncodeToString(mac.Sum(nil)) } // VerifyApprovalToken checks that a token matches the stored hash. func VerifyApprovalToken(ctx context.Context, pool *db.Pool, approvalID uuid.UUID, token string) bool { q := sqlcgen.New(pool) a, err := q.GetApprovalByID(ctx, approvalID) if err != nil || a.TokenHash == nil { return false } if a.Status != "pending" { return false } if a.ExpiresAt.Before(time.Now()) { return false } return *a.TokenHash == hashToken(token) } // hashToken double-hashes a token for storage. func hashToken(token string) string { h := sha256.Sum256([]byte(token)) return hex.EncodeToString(h[:]) }