-- Bell v3 immutable event facts and append-only outcomes. -- bell_app remains the migration/object owner; bell_runtime is a NOLOGIN -- least-privilege group for the Bell process. DO $role$ DECLARE role_record pg_roles%ROWTYPE; BEGIN SELECT * INTO role_record FROM pg_roles WHERE rolname = 'bell_runtime'; IF NOT FOUND THEN CREATE ROLE bell_runtime NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION; ELSIF role_record.rolcanlogin OR role_record.rolsuper OR role_record.rolcreatedb OR role_record.rolcreaterole OR role_record.rolreplication THEN RAISE EXCEPTION 'existing bell_runtime role has unsafe attributes'; END IF; IF pg_has_role('bell_runtime', 'bell_app', 'MEMBER') OR pg_has_role('bell_runtime', 'sense_app', 'MEMBER') THEN RAISE EXCEPTION 'bell_runtime must not inherit migration or Sense roles'; END IF; END $role$; CREATE OR REPLACE FUNCTION bell.reject_immutable_change() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, bell AS $function$ BEGIN RAISE EXCEPTION 'Bell immutable facts cannot be updated or deleted'; END $function$; ALTER FUNCTION bell.reject_immutable_change() OWNER TO bell_app; CREATE TABLE IF NOT EXISTS bell.events ( id text PRIMARY KEY, tenant_id bigint NOT NULL, site_id bigint NOT NULL, device_id bigint NOT NULL, source_event_id text NOT NULL, kind text NOT NULL, severity text NOT NULL, occurred_at timestamptz NOT NULL, detected_at timestamptz NOT NULL, payload_hash bytea NOT NULL, payload jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT clock_timestamp(), CONSTRAINT bell_event_id_format CHECK (id ~ '^evt_[0-9A-HJKMNP-TV-Z]{26}$'), CONSTRAINT bell_event_ids_positive CHECK (tenant_id >= 1 AND site_id >= 1 AND device_id >= 1), CONSTRAINT bell_event_source_id CHECK (source_event_id ~ '^[A-Za-z0-9_-]{1,128}$'), CONSTRAINT bell_event_kind CHECK (kind ~ '^[a-z][a-z0-9_]{2,63}$'), CONSTRAINT bell_event_severity CHECK (severity IN ('low', 'medium', 'high', 'critical')), CONSTRAINT bell_event_time_order CHECK (detected_at >= occurred_at), CONSTRAINT bell_event_hash_length CHECK (octet_length(payload_hash) = 32), CONSTRAINT bell_event_payload_object CHECK (jsonb_typeof(payload) = 'object'), CONSTRAINT bell_event_payload_identity CHECK ( payload ->> 'id' = id AND (payload ->> 'tenant_id')::bigint = tenant_id AND (payload ->> 'site_id')::bigint = site_id AND (payload ->> 'device_id')::bigint = device_id AND payload ->> 'source_event_id' = source_event_id AND payload ->> 'kind' = kind AND payload ->> 'severity' = severity ) ); ALTER TABLE bell.events OWNER TO bell_app; DROP TRIGGER IF EXISTS bell_events_immutable ON bell.events; CREATE TRIGGER bell_events_immutable BEFORE UPDATE OR DELETE ON bell.events FOR EACH ROW EXECUTE FUNCTION bell.reject_immutable_change(); CREATE INDEX IF NOT EXISTS bell_events_scope_time_idx ON bell.events(tenant_id, site_id, occurred_at DESC, id DESC); CREATE INDEX IF NOT EXISTS bell_events_device_time_idx ON bell.events(tenant_id, site_id, device_id, occurred_at DESC, id DESC); CREATE INDEX IF NOT EXISTS bell_events_kind_time_idx ON bell.events(tenant_id, site_id, kind, occurred_at DESC, id DESC); CREATE INDEX IF NOT EXISTS bell_events_source_lookup_idx ON bell.events(tenant_id, site_id, device_id, source_event_id); CREATE TABLE IF NOT EXISTS bell.event_outcomes ( id text PRIMARY KEY, event_id text NOT NULL REFERENCES bell.events(id), outcome text NOT NULL, outcome_source text NOT NULL, reason text, actor_type text NOT NULL, actor_id text NOT NULL, occurred_at timestamptz NOT NULL, record_hash bytea NOT NULL, received_at timestamptz NOT NULL DEFAULT clock_timestamp(), CONSTRAINT bell_event_outcome_id_format CHECK (id ~ '^out_[0-9A-HJKMNP-TV-Z]{26}$'), CONSTRAINT bell_event_outcome_value CHECK ( outcome IN ('unknown', 'true_positive', 'false_positive', 'subject_recovered', 'duplicate', 'test') ), CONSTRAINT bell_event_outcome_source CHECK (outcome_source IN ('auto', 'manual')), CONSTRAINT bell_event_outcome_actor CHECK ( actor_type IN ('user', 'service', 'system') AND btrim(actor_id) <> '' ), CONSTRAINT bell_event_outcome_reason CHECK (reason IS NULL OR char_length(reason) <= 500), CONSTRAINT bell_event_outcome_hash_length CHECK (octet_length(record_hash) = 32) ); ALTER TABLE bell.event_outcomes OWNER TO bell_app; DROP TRIGGER IF EXISTS bell_event_outcomes_immutable ON bell.event_outcomes; CREATE TRIGGER bell_event_outcomes_immutable BEFORE UPDATE OR DELETE ON bell.event_outcomes FOR EACH ROW EXECUTE FUNCTION bell.reject_immutable_change(); CREATE INDEX IF NOT EXISTS bell_event_outcomes_event_time_idx ON bell.event_outcomes(event_id, occurred_at, id); INSERT INTO bell.schema_migrations(version) VALUES (3) ON CONFLICT (version) DO NOTHING;