From 3ccea3e9f66477dd1d2a0fd573769cd0d04f9e68 Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Mon, 19 Oct 2026 02:26:46 +0100 Subject: [PATCH] [feat](alarm): keep one anonymised lifecycle event log Record created/joined/triggered/delivered/destroyed with SHA-256 hashes of the alert and user UUIDs only, and a reason on destruction. The earlier analytics and stdout-mirror tables were superseded by the user ruling, so 009 creates alarm.event_log, migrates what still applies and drops both, leaving exactly one durable table with no network identifiers, token, body or query string. Retention is age-based via the sweeper. --- database/migrations/007_alarm_analytics.sql | 48 ++++++++ database/migrations/008_log_events.sql | 56 +++++++++ .../migrations/009_consolidate_event_log.sql | 83 ++++++++++++++ src/db/analytics.go | 108 ++++++++++++++++++ 4 files changed, 295 insertions(+) create mode 100644 database/migrations/007_alarm_analytics.sql create mode 100644 database/migrations/008_log_events.sql create mode 100644 database/migrations/009_consolidate_event_log.sql create mode 100644 src/db/analytics.go diff --git a/database/migrations/007_alarm_analytics.sql b/database/migrations/007_alarm_analytics.sql new file mode 100644 index 0000000..595fce5 --- /dev/null +++ b/database/migrations/007_alarm_analytics.sql @@ -0,0 +1,48 @@ +\c dcntrlized; + +-- 007_alarm_analytics.sql — anonymized, append-only lifecycle counters. +-- +-- User request: "anonymized 'Alarm X created by Y', 'Alarm X subscribed by Y', +-- 'Alarm X delivered/triggered to/by Y', along with timestamps". Nothing raw, no +-- foreign keys, and it must survive the alert it describes — hence no REFERENCES +-- and no cascade. A destroyed alert still has its history here. +-- +-- Identity columns are SHA-256 hex of the UUID *string* form. That is genuinely +-- one-way (a UUID carries ~122 random bits, so there is no address space to +-- enumerate), which is why this file uses a plain digest while the request-log +-- IP/UA hashing uses HMAC with a secret key: an IPv4 address is only 2^32 and a +-- plain digest would be reversible by lookup. Do not "unify" the two schemes. +-- +-- `reason` is set only for `destroyed` rows: `delivered` (the ledger drained) or +-- `expired` (the persistence cap fired). +-- +-- Idempotent (same guard pattern as 002–006). + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'alarm') THEN + RETURN; + END IF; + + CREATE TABLE IF NOT EXISTS alarm.alarm_analytics ( + id BIGSERIAL PRIMARY KEY, + alert_hash TEXT NOT NULL, + user_hash TEXT, + action TEXT NOT NULL, + reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT alarm_analytics_action_check + CHECK (action IN ('created', 'subscribed', 'triggered', 'delivered', 'destroyed')) + ); + + CREATE INDEX IF NOT EXISTS alarm_analytics_alert_idx + ON alarm.alarm_analytics (alert_hash, created_at); + CREATE INDEX IF NOT EXISTS alarm_analytics_created_idx + ON alarm.alarm_analytics (created_at); + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'alarm_user') THEN + GRANT SELECT, INSERT ON alarm.alarm_analytics TO alarm_user; + GRANT USAGE, SELECT ON SEQUENCE alarm.alarm_analytics_id_seq TO alarm_user; + END IF; +END +$$; diff --git a/database/migrations/008_log_events.sql b/database/migrations/008_log_events.sql new file mode 100644 index 0000000..8bd7e54 --- /dev/null +++ b/database/migrations/008_log_events.sql @@ -0,0 +1,56 @@ +\c dcntrlized; + +-- 008_log_events.sql — durable, anonymised service log. +-- +-- User request (2026-10-18): the service should keep its operational log in the +-- database, not only on stdout, and it must not keep raw IPs or user agents. +-- Explicit columns end in `_hash` so a future reader cannot insert a raw value +-- by accident, and there is deliberately NO column for a token, request body or +-- query string: those must never be persisted (in Alarm they carry admin and +-- subscribe tokens). +-- +-- Identifier hashing is NOT the analytics scheme: +-- * alert_hash / user_hash: SHA-256 hex of the UUID (analytics scheme; UUIDs +-- carry ~122 random bits, so a plain digest is one-way). +-- * ip_hash / ua_hash: Argon2id with a service-generated public salt and a +-- tuned work factor (internal/loghash). IPs are a 2^32 space, so a plain +-- digest is a lookup table away from being reversed; only the KDF's cost +-- protects them. The two schemes are intentionally different — do not +-- "unify" them, and do not lower the Argon2id parameters to make the logger +-- faster: that would silently un-anonymise every historical row. +-- +-- All columns are nullable except id/occurred_at/level/kind/message: rows are +-- anonymised by construction, so this table can be exported for support without +-- leaking who or where. Retention is enforced by the sweeper (age + row cap), +-- see API.md; the table must never grow without bound. +-- +-- Idempotent (same guard pattern as 002–007). + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'alarm') THEN + RETURN; + END IF; + + CREATE TABLE IF NOT EXISTS alarm.log_events ( + id BIGSERIAL PRIMARY KEY, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + level TEXT NOT NULL, + kind TEXT NOT NULL, + message TEXT NOT NULL, + ip_hash TEXT, + ua_hash TEXT, + alert_hash TEXT, + user_hash TEXT, + CONSTRAINT log_events_level_check CHECK (level IN ('debug', 'info', 'warn', 'error')) + ); + + CREATE INDEX IF NOT EXISTS log_events_occurred_idx ON alarm.log_events (occurred_at); + CREATE INDEX IF NOT EXISTS log_events_kind_idx ON alarm.log_events (kind, occurred_at); + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'alarm_user') THEN + GRANT SELECT, INSERT, DELETE ON alarm.log_events TO alarm_user; + GRANT USAGE, SELECT ON SEQUENCE alarm.log_events_id_seq TO alarm_user; + END IF; +END +$$; diff --git a/database/migrations/009_consolidate_event_log.sql b/database/migrations/009_consolidate_event_log.sql new file mode 100644 index 0000000..eba887e --- /dev/null +++ b/database/migrations/009_consolidate_event_log.sql @@ -0,0 +1,83 @@ +\c dcntrlized; + +-- 009_consolidate_event_log.sql — one event table, not two. +-- +-- History: 007 created alarm.alarm_analytics (lifecycle rows with a reason) and +-- 008 created alarm.log_events (a durable mirror of stdout with hashed +-- ip/ua). The user ruling of 2026-10-18 superseded both: ONE durable table for +-- important lifecycle events only, with NO network identifiers, NO level, NO +-- message body, NO request metadata. stdout keeps the request log; this table is +-- not a mirror of it. +-- +-- Final table: alarm.event_log +-- id BIGSERIAL primary key +-- occurred_at TIMESTAMPTZ NOT NULL DEFAULT now() +-- event TEXT NOT NULL -- created | joined | triggered | delivered | destroyed +-- alert_hash TEXT NOT NULL -- SHA-256 hex of the alert UUID +-- user_hash TEXT -- SHA-256 hex of the actor/subscriber UUID +-- reason TEXT -- on 'destroyed' only: delivered|expired|disarmed|deleted +-- +-- Identities are plain SHA-256 of the UUID string (UUIDs carry ~122 random bits, +-- so a digest is one-way). There is deliberately no column for an IP, a user +-- agent, a token, a body or a query string, and adding one later would be a +-- privacy regression, not a feature. +-- +-- This migration: +-- 1. creates alarm.event_log (if missing); +-- 2. copies any rows from the two superseded tables, mapping their vocabularies; +-- 3. drops both superseded tables. +-- +-- It is IDEMPOTENT IN EFFECT for a full 001→009 re-apply: re-applying 007/008 +-- recreates the old tables empty, this file copies nothing and drops them again, +-- so the sequence always ENDS with exactly one table (alarm.event_log). + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'alarm') THEN + RETURN; + END IF; + + CREATE TABLE IF NOT EXISTS alarm.event_log ( + id BIGSERIAL PRIMARY KEY, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + event TEXT NOT NULL, + alert_hash TEXT NOT NULL, + user_hash TEXT, + reason TEXT, + CONSTRAINT event_log_event_check + CHECK (event IN ('created', 'joined', 'triggered', 'delivered', 'destroyed')) + ); + + CREATE INDEX IF NOT EXISTS event_log_alert_idx ON alarm.event_log (alert_hash, occurred_at); + CREATE INDEX IF NOT EXISTS event_log_occurred_idx ON alarm.event_log (occurred_at); + + IF to_regclass('alarm.alarm_analytics') IS NOT NULL THEN + INSERT INTO alarm.event_log (occurred_at, event, alert_hash, user_hash, reason) + SELECT created_at, + CASE action + WHEN 'created' THEN 'created' + WHEN 'subscribed' THEN 'joined' + WHEN 'triggered' THEN 'triggered' + WHEN 'delivered' THEN 'delivered' + ELSE 'destroyed' + END, + alert_hash, + user_hash, + reason + FROM alarm.alarm_analytics + ORDER BY id; + DROP TABLE alarm.alarm_analytics; + END IF; + + -- log_events held hashed network identifiers and stdout mirrors; the user + -- ruling removed both, so its rows are intentionally discarded. + IF to_regclass('alarm.log_events') IS NOT NULL THEN + DROP TABLE alarm.log_events; + END IF; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'alarm_user') THEN + GRANT SELECT, INSERT, DELETE ON alarm.event_log TO alarm_user; + GRANT USAGE, SELECT ON SEQUENCE alarm.event_log_id_seq TO alarm_user; + END IF; +END +$$; diff --git a/src/db/analytics.go b/src/db/analytics.go new file mode 100644 index 0000000..fac0cac --- /dev/null +++ b/src/db/analytics.go @@ -0,0 +1,108 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package db + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" +) + +// The one durable, anonymised lifecycle log: alarm.event_log. It records only +// important events — never stdout (that stays on stdout), never request +// metadata, never a network identifier. +// +// Event values are stable slugs; `reason` is the minimal discriminator an ending +// needs (`delivered`, `expired`, `disarmed`, `deleted`). Identities are SHA-256 +// hex of the UUID string: UUIDs carry ~122 random bits, so a plain digest is +// genuinely one-way here. +const ( + EventCreated = "created" + EventJoined = "joined" + EventTriggered = "triggered" + EventDelivered = "delivered" + EventDestroyed = "destroyed" + EventLoginFail = "login_failed" + EventJoinReject = "join_rejected" +) + +// Destruction reasons, set on `destroyed` rows only. +const ( + DestroyReasonDelivered = "delivered" // the delivery ledger drained + DestroyReasonExpired = "expired" // the owner's persistence cap fired + DestroyReasonDeleted = "deleted" // the owner explicitly deleted it + DestroyReasonDisarmed = "disarmed" // the owner disarmed it +) + +// hashUUID returns SHA-256 hex of the UUID's canonical string. +func hashUUID(id uuid.UUID) string { + sum := sha256.Sum256([]byte(id.String())) + return hex.EncodeToString(sum[:]) +} + +// HashUUID exposes the digest so tests can query rows without the raw id. +func HashUUID(id uuid.UUID) string { return hashUUID(id) } + +// execer is the subset of pgxpool.Pool / pgx.Tx the event writer needs, so a +// lifecycle row can be written inside the transaction that caused it. +type execer interface { + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) +} + +// recordEvent appends one anonymised lifecycle row. It never receives a raw id: +// callers pass UUIDs and this is the only place identities are hashed. +func recordEvent(ctx context.Context, ex execer, alertID uuid.UUID, userID *uuid.UUID, event, reason string) error { + var userHash *string + if userID != nil { + h := hashUUID(*userID) + userHash = &h + } + var reasonPtr *string + if reason != "" { + reasonPtr = &reason + } + _, err := ex.Exec(ctx, + `INSERT INTO alarm.event_log (event, alert_hash, user_hash, reason) + VALUES ($1, $2, $3, $4)`, + event, hashUUID(alertID), userHash, reasonPtr) + return err +} + +// RecordDelivered appends the `delivered` row for a device that just acknowledged +// an alarm. Callers fire it only when an ack actually cleared a ledger row, so a +// repeated ack does not inflate the count. +func RecordDelivered(ctx context.Context, alertID, userID uuid.UUID) error { + return recordEvent(ctx, Pool(), alertID, &userID, EventDelivered, "") +} + +// EventLogRetention is the age-based retention for the event log. +const EventLogRetention = 7 * 24 * time.Hour + +// PruneEventLog enforces the age-based retention: the growth table is swept with +// expiry, and seven days is enough to answer "what happened last night" without +// keeping a permanent behavioural record. Returns rows deleted. +func PruneEventLog(ctx context.Context, maxAge time.Duration) (int64, error) { + tag, err := Pool().Exec(ctx, + `DELETE FROM alarm.event_log WHERE occurred_at < now() - $1::interval`, maxAge.String()) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +}