116 lines
4.9 KiB
PL/PgSQL
116 lines
4.9 KiB
PL/PgSQL
-- 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;
|