From 3fad895d6a2f005d9124e77f428b6e276ab52253 Mon Sep 17 00:00:00 2001 From: Mike Reeves Date: Thu, 30 Apr 2026 16:30:57 -0400 Subject: [PATCH] add so_pillar schema + ext_pillar wiring (postsalt foundation) Lays the database-backed pillar foundation for the postsalt branch. Salt continues to read on-disk SLS first; the new ext_pillar config overlays values from the so_pillar.* schema in so-postgres. - salt/postgres/files/schema/pillar/00{1..7}_*.sql: idempotent DDL for scope/role/role_member/minion/pillar_entry/pillar_entry_history/ drift_log, secret pgcrypto helpers, RLS, pg_cron retention. - salt/postgres/schema_pillar.sls: applies the SQL files inside the so-postgres container after it's healthy, configures the master_key GUC, and runs so-pillar-import once. Gated on postgres:so_pillar:enabled feature flag (default false). - salt/salt/master/ext_pillar_postgres.{sls,conf.jinja}: drops /etc/salt/master.d/ext_pillar_postgres.conf with list-form ext_pillar queries (global/role/minion/secrets) and ext_pillar_first: False so bootstrap pillars on disk render before the PG overlay. - salt/postgres/init.sls + salt/salt/master.sls: include the new states. Both new state branches are guarded so a default install with the flag off is a no-op. --- .../files/schema/pillar/001_schema.sql | 124 +++++++++++++++++ .../files/schema/pillar/002_views.sql | 49 +++++++ .../schema/pillar/003_history_trigger.sql | 120 ++++++++++++++++ .../files/schema/pillar/004_secrets.sql | 130 ++++++++++++++++++ .../files/schema/pillar/005_seed_roles.sql | 39 ++++++ salt/postgres/files/schema/pillar/006_rls.sql | 96 +++++++++++++ .../files/schema/pillar/007_drift_pgcron.sql | 43 ++++++ salt/postgres/init.sls | 1 + salt/postgres/schema_pillar.sls | 113 +++++++++++++++ salt/salt/master.sls | 1 + salt/salt/master/ext_pillar_postgres.sls | 46 +++++++ .../files/ext_pillar_postgres.conf.jinja | 38 +++++ 12 files changed, 800 insertions(+) create mode 100644 salt/postgres/files/schema/pillar/001_schema.sql create mode 100644 salt/postgres/files/schema/pillar/002_views.sql create mode 100644 salt/postgres/files/schema/pillar/003_history_trigger.sql create mode 100644 salt/postgres/files/schema/pillar/004_secrets.sql create mode 100644 salt/postgres/files/schema/pillar/005_seed_roles.sql create mode 100644 salt/postgres/files/schema/pillar/006_rls.sql create mode 100644 salt/postgres/files/schema/pillar/007_drift_pgcron.sql create mode 100644 salt/postgres/schema_pillar.sls create mode 100644 salt/salt/master/ext_pillar_postgres.sls create mode 100644 salt/salt/master/files/ext_pillar_postgres.conf.jinja diff --git a/salt/postgres/files/schema/pillar/001_schema.sql b/salt/postgres/files/schema/pillar/001_schema.sql new file mode 100644 index 000000000..28aa545e5 --- /dev/null +++ b/salt/postgres/files/schema/pillar/001_schema.sql @@ -0,0 +1,124 @@ +-- so_pillar schema: queryable, versioned, audited pillar config store. +-- Replaces flat-file Salt pillar consumed via salt.pillar.postgres ext_pillar. +-- Idempotent. Run via salt/postgres/schema_pillar.sls inside the so-postgres container. + +CREATE SCHEMA IF NOT EXISTS so_pillar; + +CREATE TABLE IF NOT EXISTS so_pillar.scope ( + scope_kind text PRIMARY KEY, + precedence int NOT NULL, + description text +); + +INSERT INTO so_pillar.scope(scope_kind, precedence, description) VALUES + ('global', 100, 'Applies to every minion'), + ('role', 200, 'Applies to minions whose minion_id matches a top.sls compound role match'), + ('minion', 300, 'Applies only to a single minion (per-minion overlay)') +ON CONFLICT (scope_kind) DO NOTHING; + +CREATE TABLE IF NOT EXISTS so_pillar.role ( + role_name text PRIMARY KEY, + match_kind text NOT NULL CHECK (match_kind IN ('compound','grain','glob','list')), + match_expr text NOT NULL, + description text +); + +CREATE TABLE IF NOT EXISTS so_pillar.minion ( + minion_id text PRIMARY KEY, + node_type text, + hostname text, + extra_roles text[] NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS so_pillar.role_member ( + role_name text NOT NULL REFERENCES so_pillar.role(role_name) ON DELETE CASCADE, + minion_id text NOT NULL REFERENCES so_pillar.minion(minion_id) ON DELETE CASCADE, + source text NOT NULL DEFAULT 'computed' CHECK (source IN ('computed','manual','imported')), + PRIMARY KEY (role_name, minion_id) +); + +CREATE INDEX IF NOT EXISTS ix_role_member_minion ON so_pillar.role_member(minion_id); + +-- pillar_entry holds the actual data. as_json=True ext_pillar reads `data` directly. +CREATE TABLE IF NOT EXISTS so_pillar.pillar_entry ( + id bigserial PRIMARY KEY, + scope text NOT NULL REFERENCES so_pillar.scope(scope_kind), + role_name text REFERENCES so_pillar.role(role_name) ON DELETE CASCADE, + minion_id text REFERENCES so_pillar.minion(minion_id) ON DELETE CASCADE, + pillar_path text NOT NULL, + data jsonb NOT NULL, + is_secret boolean NOT NULL DEFAULT false, + sort_key int NOT NULL DEFAULT 0, + version int NOT NULL DEFAULT 1, + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by text NOT NULL DEFAULT current_user, + change_reason text, + CONSTRAINT pillar_entry_scope_target CHECK ( + (scope='global' AND role_name IS NULL AND minion_id IS NULL) + OR (scope='role' AND role_name IS NOT NULL AND minion_id IS NULL) + OR (scope='minion' AND role_name IS NULL AND minion_id IS NOT NULL) + ), + -- Reserved namespaces that MUST stay rendered from SLS (mine-driven). Nothing + -- under these prefixes is allowed in the database; the merge logic relies on + -- ext_pillar leaving these subtrees alone. + CONSTRAINT pillar_entry_reserved_paths CHECK ( + pillar_path NOT LIKE 'elasticsearch.nodes%' + AND pillar_path NOT LIKE 'redis.nodes%' + AND pillar_path NOT LIKE 'kafka.nodes%' + AND pillar_path NOT LIKE 'hypervisor.nodes%' + AND pillar_path NOT LIKE 'logstash.nodes%' + AND pillar_path NOT LIKE 'node_data.ips%' + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS ux_pillar_entry_global ON so_pillar.pillar_entry(pillar_path) + WHERE scope = 'global'; +CREATE UNIQUE INDEX IF NOT EXISTS ux_pillar_entry_role ON so_pillar.pillar_entry(role_name, pillar_path) + WHERE scope = 'role'; +CREATE UNIQUE INDEX IF NOT EXISTS ux_pillar_entry_minion ON so_pillar.pillar_entry(minion_id, pillar_path) + WHERE scope = 'minion'; + +CREATE INDEX IF NOT EXISTS ix_pillar_entry_minion_hot ON so_pillar.pillar_entry(minion_id) + WHERE scope = 'minion'; +CREATE INDEX IF NOT EXISTS ix_pillar_entry_role_hot ON so_pillar.pillar_entry(role_name) + WHERE scope = 'role'; + +-- Append-only audit log for every change to pillar_entry. No FK to entry so DELETE +-- history survives the row removal. +CREATE TABLE IF NOT EXISTS so_pillar.pillar_entry_history ( + history_id bigserial PRIMARY KEY, + entry_id bigint, + op text NOT NULL CHECK (op IN ('INSERT','UPDATE','DELETE')), + scope text NOT NULL, + role_name text, + minion_id text, + pillar_path text NOT NULL, + old_data jsonb, + new_data jsonb, + is_secret boolean, + version int, + changed_at timestamptz NOT NULL DEFAULT now(), + changed_by text NOT NULL DEFAULT current_user, + change_reason text +); + +CREATE INDEX IF NOT EXISTS ix_pillar_history_entry ON so_pillar.pillar_entry_history(entry_id, changed_at DESC); +CREATE INDEX IF NOT EXISTS ix_pillar_history_minion ON so_pillar.pillar_entry_history(minion_id, changed_at DESC); +CREATE INDEX IF NOT EXISTS ix_pillar_history_role ON so_pillar.pillar_entry_history(role_name, changed_at DESC); + +-- Drift watch — populated by a pg_cron job that re-renders the on-disk SLS files +-- and compares them to pillar_entry. Cleared once cutover completes. +CREATE TABLE IF NOT EXISTS so_pillar.drift_log ( + id bigserial PRIMARY KEY, + scope text NOT NULL, + role_name text, + minion_id text, + pillar_path text NOT NULL, + disk_data jsonb, + db_data jsonb, + detected_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_drift_log_detected ON so_pillar.drift_log(detected_at DESC); diff --git a/salt/postgres/files/schema/pillar/002_views.sql b/salt/postgres/files/schema/pillar/002_views.sql new file mode 100644 index 000000000..b5cffdaaf --- /dev/null +++ b/salt/postgres/files/schema/pillar/002_views.sql @@ -0,0 +1,49 @@ +-- Views consumed by the Salt master's salt.pillar.postgres ext_pillar with +-- as_json=True. Each view exposes data ordered by (sort_key, pillar_path) so +-- the deep-merge in ext_pillar resolves precedence deterministically. +-- +-- ext_pillar always binds exactly one parameter to the query: (minion_id,). +-- Master-config queries reference these views and add WHERE clauses, e.g.: +-- SELECT data FROM so_pillar.v_pillar_role WHERE minion_id = %s +-- SELECT data FROM so_pillar.v_pillar_minion WHERE minion_id = %s +-- For v_pillar_global the binding is satisfied with `WHERE %s IS NOT NULL`. + +CREATE OR REPLACE VIEW so_pillar.v_pillar_global AS + SELECT pillar_path, sort_key, data + FROM so_pillar.pillar_entry + WHERE scope = 'global' + AND is_secret = false + ORDER BY sort_key, pillar_path; + +-- Role view exposes minion_id so the master-config WHERE clause can filter to +-- the rows that apply to the requesting minion. JOIN to role_member fans out +-- one row per (role assignment, pillar entry) tuple. +CREATE OR REPLACE VIEW so_pillar.v_pillar_role AS + SELECT rm.minion_id, + pe.role_name, + pe.pillar_path, + pe.sort_key, + pe.data + FROM so_pillar.pillar_entry pe + JOIN so_pillar.role_member rm ON rm.role_name = pe.role_name + WHERE pe.scope = 'role' + AND pe.is_secret = false; + +CREATE OR REPLACE VIEW so_pillar.v_pillar_minion AS + SELECT minion_id, + pillar_path, + sort_key, + data + FROM so_pillar.pillar_entry + WHERE scope = 'minion' + AND is_secret = false; + +-- v_pillar_secrets is filled in by 004_secrets.sql once pgcrypto is available; +-- placeholder here returns no rows so initial schema deploy succeeds even on a +-- container that has not yet loaded pgcrypto. +CREATE OR REPLACE VIEW so_pillar.v_pillar_secrets AS + SELECT NULL::text AS minion_id, + NULL::text AS pillar_path, + NULL::int AS sort_key, + '{}'::jsonb AS data + WHERE false; diff --git a/salt/postgres/files/schema/pillar/003_history_trigger.sql b/salt/postgres/files/schema/pillar/003_history_trigger.sql new file mode 100644 index 000000000..941c78d13 --- /dev/null +++ b/salt/postgres/files/schema/pillar/003_history_trigger.sql @@ -0,0 +1,120 @@ +-- Audit trigger: every INSERT/UPDATE/DELETE on so_pillar.pillar_entry writes a +-- row to pillar_entry_history. Captures the actor (current_user), reason +-- (passed via SET LOCAL so_pillar.change_reason), and full before/after data. + +CREATE OR REPLACE FUNCTION so_pillar.fn_pillar_entry_audit() RETURNS trigger +LANGUAGE plpgsql AS $fn$ +DECLARE + v_reason text := current_setting('so_pillar.change_reason', true); +BEGIN + IF (TG_OP = 'INSERT') THEN + INSERT INTO so_pillar.pillar_entry_history( + entry_id, op, scope, role_name, minion_id, pillar_path, + old_data, new_data, is_secret, version, changed_by, change_reason) + VALUES (NEW.id, 'INSERT', NEW.scope, NEW.role_name, NEW.minion_id, NEW.pillar_path, + NULL, NEW.data, NEW.is_secret, NEW.version, NEW.updated_by, v_reason); + RETURN NEW; + ELSIF (TG_OP = 'UPDATE') THEN + IF OLD.data IS DISTINCT FROM NEW.data + OR OLD.is_secret IS DISTINCT FROM NEW.is_secret THEN + INSERT INTO so_pillar.pillar_entry_history( + entry_id, op, scope, role_name, minion_id, pillar_path, + old_data, new_data, is_secret, version, changed_by, change_reason) + VALUES (NEW.id, 'UPDATE', NEW.scope, NEW.role_name, NEW.minion_id, NEW.pillar_path, + OLD.data, NEW.data, NEW.is_secret, NEW.version, NEW.updated_by, v_reason); + END IF; + RETURN NEW; + ELSIF (TG_OP = 'DELETE') THEN + INSERT INTO so_pillar.pillar_entry_history( + entry_id, op, scope, role_name, minion_id, pillar_path, + old_data, new_data, is_secret, version, changed_by, change_reason) + VALUES (OLD.id, 'DELETE', OLD.scope, OLD.role_name, OLD.minion_id, OLD.pillar_path, + OLD.data, NULL, OLD.is_secret, OLD.version, current_user, v_reason); + RETURN OLD; + END IF; + RETURN NULL; +END +$fn$; + +DROP TRIGGER IF EXISTS pillar_entry_audit ON so_pillar.pillar_entry; +CREATE TRIGGER pillar_entry_audit + AFTER INSERT OR UPDATE OR DELETE ON so_pillar.pillar_entry + FOR EACH ROW EXECUTE FUNCTION so_pillar.fn_pillar_entry_audit(); + +-- updated_at + version maintenance: bump version on every UPDATE that changes data. +CREATE OR REPLACE FUNCTION so_pillar.fn_pillar_entry_versioning() RETURNS trigger +LANGUAGE plpgsql AS $fn$ +BEGIN + IF (TG_OP = 'UPDATE') THEN + IF OLD.data IS DISTINCT FROM NEW.data + OR OLD.is_secret IS DISTINCT FROM NEW.is_secret THEN + NEW.version := OLD.version + 1; + NEW.updated_at := now(); + ELSE + NEW.version := OLD.version; + NEW.updated_at := OLD.updated_at; + END IF; + END IF; + RETURN NEW; +END +$fn$; + +DROP TRIGGER IF EXISTS pillar_entry_versioning ON so_pillar.pillar_entry; +CREATE TRIGGER pillar_entry_versioning + BEFORE UPDATE ON so_pillar.pillar_entry + FOR EACH ROW EXECUTE FUNCTION so_pillar.fn_pillar_entry_versioning(); + +-- Recompute role_member rows for a minion based on node_type. +-- Compound matchers in pillar/top.sls are pure suffix patterns of the form +-- '*_' plus the special multi-role 'manager/managersearch/managerhype' +-- bucket. node_type is split on common dashes/underscores; any token that +-- matches a known role_name produces a role_member row. +CREATE OR REPLACE FUNCTION so_pillar.fn_recompute_role_members(p_minion_id text) +RETURNS void LANGUAGE plpgsql AS $fn$ +DECLARE + v_node_type text; + v_extra text[]; + v_role text; +BEGIN + SELECT node_type, extra_roles INTO v_node_type, v_extra + FROM so_pillar.minion WHERE minion_id = p_minion_id; + + IF v_node_type IS NULL THEN + RETURN; + END IF; + + DELETE FROM so_pillar.role_member + WHERE minion_id = p_minion_id AND source = 'computed'; + + -- Main role from node_type. + IF EXISTS (SELECT 1 FROM so_pillar.role WHERE role_name = lower(v_node_type)) THEN + INSERT INTO so_pillar.role_member(role_name, minion_id, source) + VALUES (lower(v_node_type), p_minion_id, 'computed') + ON CONFLICT DO NOTHING; + END IF; + + -- Extra roles supplied by the importer / reactor for compound matchers + -- that need to apply multiple buckets (e.g. managersearch also gets the + -- 'manager' bucket per top.sls line 36 grouping). + FOREACH v_role IN ARRAY COALESCE(v_extra, '{}'::text[]) LOOP + IF EXISTS (SELECT 1 FROM so_pillar.role WHERE role_name = v_role) THEN + INSERT INTO so_pillar.role_member(role_name, minion_id, source) + VALUES (v_role, p_minion_id, 'computed') + ON CONFLICT DO NOTHING; + END IF; + END LOOP; +END +$fn$; + +CREATE OR REPLACE FUNCTION so_pillar.fn_minion_after_change() RETURNS trigger +LANGUAGE plpgsql AS $fn$ +BEGIN + PERFORM so_pillar.fn_recompute_role_members(COALESCE(NEW.minion_id, OLD.minion_id)); + RETURN COALESCE(NEW, OLD); +END +$fn$; + +DROP TRIGGER IF EXISTS minion_role_sync ON so_pillar.minion; +CREATE TRIGGER minion_role_sync + AFTER INSERT OR UPDATE OF node_type, extra_roles ON so_pillar.minion + FOR EACH ROW EXECUTE FUNCTION so_pillar.fn_minion_after_change(); diff --git a/salt/postgres/files/schema/pillar/004_secrets.sql b/salt/postgres/files/schema/pillar/004_secrets.sql new file mode 100644 index 000000000..299556d55 --- /dev/null +++ b/salt/postgres/files/schema/pillar/004_secrets.sql @@ -0,0 +1,130 @@ +-- pgcrypto-backed secret storage for pillar_entry rows where is_secret = true. +-- The plaintext value is encrypted with a symmetric key held in a server-side +-- GUC (so_pillar.master_key) which is set per-role via ALTER ROLE so the key +-- never touches a flat file readable by Salt itself. + +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; + +-- Encrypt a JSONB value using the configured master key. Stored as a JSONB +-- envelope {"_enc": ""} so the same column type is reused. +CREATE OR REPLACE FUNCTION so_pillar.fn_encrypt_jsonb(p_value jsonb) +RETURNS jsonb LANGUAGE plpgsql AS $fn$ +DECLARE + v_key text := current_setting('so_pillar.master_key', true); +BEGIN + IF v_key IS NULL OR v_key = '' THEN + RAISE EXCEPTION 'so_pillar.master_key GUC not configured'; + END IF; + RETURN jsonb_build_object( + '_enc', + encode(pgp_sym_encrypt(p_value::text, v_key), 'base64') + ); +END +$fn$; + +-- Decrypt the envelope produced by fn_encrypt_jsonb. SECURITY DEFINER so callers +-- with no direct access to pgcrypto/master_key can still pull plaintext via the +-- v_pillar_secrets view. +CREATE OR REPLACE FUNCTION so_pillar.fn_decrypt_jsonb(p_envelope jsonb) +RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER AS $fn$ +DECLARE + v_key text := current_setting('so_pillar.master_key', true); + v_ct text; +BEGIN + IF v_key IS NULL OR v_key = '' THEN + RAISE EXCEPTION 'so_pillar.master_key GUC not configured'; + END IF; + v_ct := p_envelope->>'_enc'; + IF v_ct IS NULL THEN + RETURN p_envelope; -- not encrypted; pass through + END IF; + RETURN pgp_sym_decrypt(decode(v_ct, 'base64'), v_key)::jsonb; +END +$fn$; + +REVOKE ALL ON FUNCTION so_pillar.fn_decrypt_jsonb(jsonb) FROM PUBLIC; + +-- Secrets view consumed by ext_pillar. Decrypts at the boundary so Salt sees +-- plaintext JSONB. Filters the rows to those that apply to the requesting +-- minion via current_setting, since views can't take parameters and ext_pillar +-- can only bind one parameter per query. +-- +-- Master-config query: SELECT data FROM so_pillar.v_pillar_secrets WHERE %s IS NOT NULL +-- The %s satisfies the bound parameter; the view itself reads the minion_id +-- from a session GUC set by a small wrapper function (see fn_pillar_secrets). +CREATE OR REPLACE FUNCTION so_pillar.fn_pillar_secrets(p_minion_id text) +RETURNS TABLE(data jsonb) +LANGUAGE sql STABLE SECURITY DEFINER AS $fn$ + SELECT so_pillar.fn_decrypt_jsonb(pe.data) + FROM so_pillar.pillar_entry pe + WHERE pe.is_secret = true + AND ( pe.scope = 'global' + OR (pe.scope = 'role' + AND pe.role_name IN ( + SELECT role_name FROM so_pillar.role_member + WHERE minion_id = p_minion_id)) + OR (pe.scope = 'minion' AND pe.minion_id = p_minion_id)) + ORDER BY pe.sort_key, pe.pillar_path; +$fn$; + +-- Replace the placeholder view from 002 with a parameterised version. Master +-- config query becomes: +-- SELECT data FROM so_pillar.fn_pillar_secrets(%s) AS s +DROP VIEW IF EXISTS so_pillar.v_pillar_secrets; +CREATE OR REPLACE VIEW so_pillar.v_pillar_secrets AS + SELECT NULL::text AS minion_id, + NULL::text AS pillar_path, + NULL::int AS sort_key, + '{}'::jsonb AS data + WHERE false; +COMMENT ON VIEW so_pillar.v_pillar_secrets IS + 'Deprecated placeholder; use SELECT data FROM so_pillar.fn_pillar_secrets(minion_id) instead'; + +-- Convenience helper for so-yaml.py and the importer to set a secret without +-- ever exposing the master_key to the caller. SECURITY DEFINER means the +-- caller does not need read access to so_pillar.master_key. +CREATE OR REPLACE FUNCTION so_pillar.fn_set_secret( + p_scope text, + p_role_name text, + p_minion_id text, + p_pillar_path text, + p_value jsonb, + p_change_reason text DEFAULT NULL +) RETURNS bigint LANGUAGE plpgsql SECURITY DEFINER AS $fn$ +DECLARE + v_envelope jsonb := so_pillar.fn_encrypt_jsonb(p_value); + v_id bigint; +BEGIN + PERFORM set_config('so_pillar.change_reason', + COALESCE(p_change_reason, 'fn_set_secret'), + true); + + INSERT INTO so_pillar.pillar_entry( + scope, role_name, minion_id, pillar_path, data, is_secret, change_reason) + VALUES (p_scope, p_role_name, p_minion_id, p_pillar_path, v_envelope, true, p_change_reason) + ON CONFLICT (pillar_path) WHERE scope='global' DO UPDATE + SET data = EXCLUDED.data, is_secret = true, change_reason = EXCLUDED.change_reason + RETURNING id INTO v_id; + + IF v_id IS NULL THEN + UPDATE so_pillar.pillar_entry + SET data = v_envelope, is_secret = true, change_reason = p_change_reason + WHERE scope = p_scope + AND COALESCE(role_name,'') = COALESCE(p_role_name,'') + AND COALESCE(minion_id,'') = COALESCE(p_minion_id,'') + AND pillar_path = p_pillar_path + RETURNING id INTO v_id; + + IF v_id IS NULL THEN + INSERT INTO so_pillar.pillar_entry( + scope, role_name, minion_id, pillar_path, data, is_secret, change_reason) + VALUES (p_scope, p_role_name, p_minion_id, p_pillar_path, v_envelope, true, p_change_reason) + RETURNING id INTO v_id; + END IF; + END IF; + + RETURN v_id; +END +$fn$; + +REVOKE ALL ON FUNCTION so_pillar.fn_set_secret(text,text,text,text,jsonb,text) FROM PUBLIC; diff --git a/salt/postgres/files/schema/pillar/005_seed_roles.sql b/salt/postgres/files/schema/pillar/005_seed_roles.sql new file mode 100644 index 000000000..f66c14e59 --- /dev/null +++ b/salt/postgres/files/schema/pillar/005_seed_roles.sql @@ -0,0 +1,39 @@ +-- Seed the so_pillar.role table with the role buckets defined in pillar/top.sls. +-- The match_expr column preserves the original Salt compound expression purely +-- as documentation; PG-side membership is materialised in role_member. +-- Idempotent: ON CONFLICT lets re-application leave existing rows untouched. + +INSERT INTO so_pillar.role(role_name, match_kind, match_expr, description) VALUES + ('manager', 'compound', '*_manager or *_managersearch or *_managerhype', + 'Manager-class node. Includes managersearch and managerhype subtypes.'), + ('managersearch', 'compound', '*_managersearch', + 'Combined manager + searchnode role.'), + ('managerhype', 'compound', '*_managerhype', + 'Combined manager + hypervisor role.'), + ('sensor', 'compound', '*_sensor', + 'Sensor node running zeek/suricata/strelka.'), + ('eval', 'compound', '*_eval', + 'Single-node evaluation install (manager + sensor + storage on one host).'), + ('standalone', 'compound', '*_standalone', + 'Single-node production install (no distributed cluster).'), + ('heavynode', 'compound', '*_heavynode', + 'Distributed manager node carrying logstash + ES.'), + ('idh', 'compound', '*_idh', + 'Intrusion-detection-honeypot node.'), + ('searchnode', 'compound', '*_searchnode', + 'Distributed Elasticsearch search node.'), + ('receiver', 'compound', '*_receiver', + 'Kafka receiver node.'), + ('import', 'compound', '*_import', + 'Single-node import-only install.'), + ('fleet', 'compound', '*_fleet', + 'Elastic Fleet server node.'), + ('hypervisor', 'compound', '*_hypervisor', + 'Hypervisor host (libvirt). Hosts VM minions.'), + ('desktop', 'compound', '*_desktop', + 'Desktop minion (no firewall/nginx pillars apply).'), + ('not_desktop', 'compound', '* and not *_desktop', + 'Pseudo-role; matches every minion that is not a desktop. Used for global firewall/nginx.'), + ('libvirt', 'grain', 'salt-cloud:driver:libvirt', + 'Pseudo-role; matches any minion with grain salt-cloud.driver = libvirt.') +ON CONFLICT (role_name) DO NOTHING; diff --git a/salt/postgres/files/schema/pillar/006_rls.sql b/salt/postgres/files/schema/pillar/006_rls.sql new file mode 100644 index 000000000..dba890fc3 --- /dev/null +++ b/salt/postgres/files/schema/pillar/006_rls.sql @@ -0,0 +1,96 @@ +-- Roles + Row-Level Security policies for the so_pillar schema. +-- Three roles: +-- so_pillar_master — connected by salt-master ext_pillar. Read-only. +-- RLS forces it to skip is_secret rows; reads +-- encrypted secrets only via fn_pillar_secrets(). +-- so_pillar_writer — connected by so-yaml dual-write and the SOC +-- PostgresConfigstore. Read+write on pillar_entry, +-- minion, role_member. +-- so_pillar_secret_owner — owns the master encryption key GUC; sole role +-- allowed to call fn_set_secret directly. Other +-- writers reach this function only via grants. +-- +-- The existing app role so_postgres_user (created by init-users.sh) is granted +-- INTO so_pillar_writer so SOC keeps using its existing connection but inherits +-- pillar-write capability. + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'so_pillar_master') THEN + CREATE ROLE so_pillar_master NOLOGIN; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'so_pillar_writer') THEN + CREATE ROLE so_pillar_writer NOLOGIN; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'so_pillar_secret_owner') THEN + CREATE ROLE so_pillar_secret_owner NOLOGIN; + END IF; +END +$$; + +GRANT USAGE ON SCHEMA so_pillar TO so_pillar_master, so_pillar_writer, so_pillar_secret_owner; + +-- Read access for ext_pillar through the views only. +GRANT SELECT ON so_pillar.v_pillar_global, + so_pillar.v_pillar_role, + so_pillar.v_pillar_minion + TO so_pillar_master; +GRANT EXECUTE ON FUNCTION so_pillar.fn_pillar_secrets(text) TO so_pillar_master; + +-- Writer needs CRUD on pillar_entry/minion/role_member plus access to seed tables. +GRANT SELECT, INSERT, UPDATE, DELETE + ON so_pillar.pillar_entry, + so_pillar.minion, + so_pillar.role_member + TO so_pillar_writer; +GRANT SELECT ON so_pillar.role, so_pillar.scope TO so_pillar_writer; +GRANT SELECT, INSERT, UPDATE, DELETE ON so_pillar.drift_log TO so_pillar_writer; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA so_pillar TO so_pillar_writer; +GRANT SELECT ON so_pillar.pillar_entry_history TO so_pillar_writer; + +-- Secret owner can call fn_set_secret directly; writer goes through it via the +-- function's SECURITY DEFINER attribute, which executes as the function owner. +GRANT EXECUTE ON FUNCTION so_pillar.fn_set_secret(text,text,text,text,jsonb,text) + TO so_pillar_writer, so_pillar_secret_owner; + +-- so_postgres_user (SOC's existing app user, created by init-users.sh) inherits +-- writer privilege so the PostgresConfigstore in SOC can mutate pillars without +-- a second connection pool. Inheritance is per-PG default (NOINHERIT must be +-- explicit), so this just works. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = current_setting('so_pillar.app_role', true)) + THEN + EXECUTE format('GRANT so_pillar_writer TO %I', + current_setting('so_pillar.app_role', true)); + ELSIF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'so_postgres_user') THEN + GRANT so_pillar_writer TO so_postgres_user; + END IF; +END +$$; + +-- RLS on pillar_entry: master sees only non-secret rows. Writer sees all +-- (it must, to UPDATE secret rows when so-yaml replaces them). Secret rows +-- still require fn_decrypt_jsonb to read plaintext. +ALTER TABLE so_pillar.pillar_entry ENABLE ROW LEVEL SECURITY; +ALTER TABLE so_pillar.pillar_entry FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS pillar_entry_master_read ON so_pillar.pillar_entry; +DROP POLICY IF EXISTS pillar_entry_writer_all ON so_pillar.pillar_entry; +DROP POLICY IF EXISTS pillar_entry_owner_all ON so_pillar.pillar_entry; + +CREATE POLICY pillar_entry_master_read ON so_pillar.pillar_entry + FOR SELECT TO so_pillar_master + USING (NOT is_secret); + +CREATE POLICY pillar_entry_writer_all ON so_pillar.pillar_entry + FOR ALL TO so_pillar_writer + USING (true) + WITH CHECK (true); + +CREATE POLICY pillar_entry_owner_all ON so_pillar.pillar_entry + FOR ALL TO so_pillar_secret_owner + USING (true) + WITH CHECK (true); + +-- minion / role_member do not need RLS — they hold no secrets. diff --git a/salt/postgres/files/schema/pillar/007_drift_pgcron.sql b/salt/postgres/files/schema/pillar/007_drift_pgcron.sql new file mode 100644 index 000000000..b19b65cf8 --- /dev/null +++ b/salt/postgres/files/schema/pillar/007_drift_pgcron.sql @@ -0,0 +1,43 @@ +-- Drift detection + retention via pg_cron. Optional — the schema_pillar.sls +-- state guards this file behind the postgres:so_pillar:drift_check_enabled +-- pillar flag because pg_cron may not be loaded on every install. + +CREATE EXTENSION IF NOT EXISTS pg_cron; + +-- Retention: trim pillar_entry_history older than a year. Adjustable via the +-- so_pillar.history_retention_days GUC (default 365 if unset). +CREATE OR REPLACE FUNCTION so_pillar.fn_history_retain() +RETURNS void LANGUAGE plpgsql AS $fn$ +DECLARE + v_days int := COALESCE(current_setting('so_pillar.history_retention_days', true)::int, 365); +BEGIN + DELETE FROM so_pillar.pillar_entry_history + WHERE changed_at < (now() - (v_days::text || ' days')::interval); +END +$fn$; + +-- Drift retention: keep two weeks of drift_log. +CREATE OR REPLACE FUNCTION so_pillar.fn_drift_retain() +RETURNS void LANGUAGE plpgsql AS $fn$ +BEGIN + DELETE FROM so_pillar.drift_log + WHERE detected_at < (now() - interval '14 days'); +END +$fn$; + +-- pg_cron schedules (idempotent — unschedule any existing same-named job first). +DO $$ +DECLARE + v_jobid bigint; +BEGIN + SELECT jobid INTO v_jobid FROM cron.job WHERE jobname = 'so_pillar_history_retain'; + IF v_jobid IS NOT NULL THEN PERFORM cron.unschedule(v_jobid); END IF; + PERFORM cron.schedule('so_pillar_history_retain', '15 3 * * *', + 'SELECT so_pillar.fn_history_retain();'); + + SELECT jobid INTO v_jobid FROM cron.job WHERE jobname = 'so_pillar_drift_retain'; + IF v_jobid IS NOT NULL THEN PERFORM cron.unschedule(v_jobid); END IF; + PERFORM cron.schedule('so_pillar_drift_retain', '20 3 * * *', + 'SELECT so_pillar.fn_drift_retain();'); +END +$$; diff --git a/salt/postgres/init.sls b/salt/postgres/init.sls index 2e3c9ffb7..93fa8b103 100644 --- a/salt/postgres/init.sls +++ b/salt/postgres/init.sls @@ -8,6 +8,7 @@ include: {% if PGMERGED.enabled %} - postgres.enabled + - postgres.schema_pillar {% else %} - postgres.disabled {% endif %} diff --git a/salt/postgres/schema_pillar.sls b/salt/postgres/schema_pillar.sls new file mode 100644 index 000000000..22599a332 --- /dev/null +++ b/salt/postgres/schema_pillar.sls @@ -0,0 +1,113 @@ +# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one +# or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at +# https://securityonion.net/license; you may not use this file except in compliance with the +# Elastic License 2.0. + +{% from 'allowed_states.map.jinja' import allowed_states %} +{% if sls.split('.')[0] in allowed_states %} +{% from 'vars/globals.map.jinja' import GLOBALS %} + +# Deploys the so_pillar schema (tables, views, audit triggers, secrets, +# RLS, pg_cron retention) inside the so-postgres container. Idempotent — +# every CREATE / GRANT is wrapped in IF NOT EXISTS / ON CONFLICT or DO +# blocks so re-running the state is a no-op when the schema is current. +# +# Gated on the postgres:so_pillar:enabled feature flag (default false). +# Flip to true once the postsalt branch is ready to bring ext_pillar live. + +include: + - postgres.enabled + +{% set so_pillar_enabled = salt['pillar.get']('postgres:so_pillar:enabled', False) %} +{% if so_pillar_enabled %} + +{% set drift_enabled = salt['pillar.get']('postgres:so_pillar:drift_check_enabled', False) %} +{% set schema_dir = '/opt/so/saltstack/default/salt/postgres/files/schema/pillar' %} + +# Wait for postgres to actually accept TCP connections. Same idiom as +# telegraf_users.sls. The docker_container.running state returns earlier than +# the database is ready on first init. +so_pillar_postgres_wait_ready: + cmd.run: + - name: | + for i in $(seq 1 60); do + if docker exec so-postgres pg_isready -h 127.0.0.1 -U postgres -q 2>/dev/null; then + exit 0 + fi + sleep 2 + done + echo "so-postgres did not accept TCP connections within 120s" >&2 + exit 1 + - require: + - docker_container: so-postgres + +{% set sql_files = [ + '001_schema.sql', + '002_views.sql', + '003_history_trigger.sql', + '004_secrets.sql', + '005_seed_roles.sql', + '006_rls.sql', +] %} + +{% if drift_enabled %} +{% do sql_files.append('007_drift_pgcron.sql') %} +{% endif %} + +{% for sql_file in sql_files %} +so_pillar_apply_{{ sql_file | replace('.', '_') }}: + cmd.run: + - name: | + docker exec -i so-postgres psql -v ON_ERROR_STOP=1 -U postgres -d securityonion \ + < {{ schema_dir }}/{{ sql_file }} + - require: + - cmd: so_pillar_postgres_wait_ready +{% if not loop.first %} + - cmd: so_pillar_apply_{{ sql_files[loop.index0 - 1] | replace('.', '_') }} +{% endif %} +{% endfor %} + +# Set the master encryption key GUC on the secret-owner role. The key itself +# is generated by setup/so-functions::secrets_pillar() (extended for postsalt) +# and lives in /opt/so/conf/postgres/so_pillar.key (mode 0400) — never read by +# Salt itself; the value flows into PG via ALTER ROLE so it sits only in the +# server's role catalog. +so_pillar_master_key_configure: + cmd.run: + - name: | + if [ -r /opt/so/conf/postgres/so_pillar.key ]; then + KEY="$(< /opt/so/conf/postgres/so_pillar.key)" + docker exec -i so-postgres psql -v ON_ERROR_STOP=1 -U postgres -d securityonion <&2 + exit 1 + fi + - require: + - cmd: so_pillar_apply_006_rls_sql + +# Run the importer once after the schema is in place. Idempotent — re-runs +# with no SLS edits produce zero row changes. +so_pillar_initial_import: + cmd.run: + - name: /usr/sbin/so-pillar-import --yes --reason 'schema_pillar.sls initial import' + - require: + - cmd: so_pillar_master_key_configure + +{% else %} + +so_pillar_disabled_noop: + test.nop + +{% endif %} + +{% else %} + +{{sls}}_state_not_allowed: + test.fail_without_changes: + - name: {{sls}}_state_not_allowed + +{% endif %} diff --git a/salt/salt/master.sls b/salt/salt/master.sls index 895150cd7..01a6a92a8 100644 --- a/salt/salt/master.sls +++ b/salt/salt/master.sls @@ -14,6 +14,7 @@ include: - salt.minion + - salt.master.ext_pillar_postgres {% if 'vrt' in salt['pillar.get']('features', []) %} - salt.cloud - salt.cloud.reactor_config_hypervisor diff --git a/salt/salt/master/ext_pillar_postgres.sls b/salt/salt/master/ext_pillar_postgres.sls new file mode 100644 index 000000000..9ed071f38 --- /dev/null +++ b/salt/salt/master/ext_pillar_postgres.sls @@ -0,0 +1,46 @@ +# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one +# or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at +# https://securityonion.net/license; you may not use this file except in compliance with the +# Elastic License 2.0. + +# Drops /etc/salt/master.d/ext_pillar_postgres.conf so the salt-master loads +# pillar overlays from the so_pillar.* schema in so-postgres alongside the +# on-disk SLS pillar tree. Gated on the postgres:so_pillar:enabled feature +# flag (default false) so the file only appears once the schema is deployed +# and the importer has run at least once. + +{% from 'allowed_states.map.jinja' import allowed_states %} +{% if sls.split('.')[0] in allowed_states %} + +{% if salt['pillar.get']('postgres:so_pillar:enabled', False) %} + +ext_pillar_postgres_config: + file.managed: + - name: /etc/salt/master.d/ext_pillar_postgres.conf + - source: salt://salt/master/files/ext_pillar_postgres.conf.jinja + - template: jinja + - mode: '0640' + - user: root + - group: salt + - watch_in: + - service: salt_master_service + +{% else %} + +# When the flag is off make sure any previously-deployed config is removed +# so a rollback flips behavior cleanly. +ext_pillar_postgres_config_absent: + file.absent: + - name: /etc/salt/master.d/ext_pillar_postgres.conf + - watch_in: + - service: salt_master_service + +{% endif %} + +{% else %} + +{{sls}}_state_not_allowed: + test.fail_without_changes: + - name: {{sls}}_state_not_allowed + +{% endif %} diff --git a/salt/salt/master/files/ext_pillar_postgres.conf.jinja b/salt/salt/master/files/ext_pillar_postgres.conf.jinja new file mode 100644 index 000000000..4e3f0f2d3 --- /dev/null +++ b/salt/salt/master/files/ext_pillar_postgres.conf.jinja @@ -0,0 +1,38 @@ +# /etc/salt/master.d/ext_pillar_postgres.conf +# Rendered by salt/salt/master/ext_pillar_postgres.sls. +# Reads the so_pillar.* schema in so-postgres and overlays it onto SLS pillar. +# SLS still renders first (ext_pillar_first: False) so bootstrap and mine-driven +# pillars work before Postgres is reachable; PG values overlay/override on top. + +postgres: + host: {{ pillar.get('postgres', {}).get('host', '127.0.0.1') }} + port: {{ pillar.get('postgres', {}).get('port', 5432) }} + db: securityonion + user: so_pillar_master + pass: {{ pillar['secrets']['pillar_master_pass'] }} + +ext_pillar_first: False +pillar_source_merging_strategy: smart +pillar_merge_lists: False + +pillar_cache: True +pillar_cache_backend: disk +pillar_cache_ttl: {{ pillar.get('postgres', {}).get('so_pillar', {}).get('pillar_cache_ttl', 60) }} + +# List form (not mapping form) so result rows merge into the pillar root rather +# than under a named subtree. Verified against salt/pillar/sql_base.py: list +# entries pass root=None to enter_root() which sets self.focus = self.result. +ext_pillar: + - postgres: + - query: "SELECT data FROM so_pillar.v_pillar_global WHERE %s IS NOT NULL ORDER BY sort_key, pillar_path" + as_json: True + ignore_null: True + - query: "SELECT data FROM so_pillar.v_pillar_role WHERE minion_id = %s ORDER BY sort_key, pillar_path" + as_json: True + ignore_null: True + - query: "SELECT data FROM so_pillar.v_pillar_minion WHERE minion_id = %s ORDER BY sort_key, pillar_path" + as_json: True + ignore_null: True + - query: "SELECT data FROM so_pillar.fn_pillar_secrets(%s)" + as_json: True + ignore_null: True