mirror of
https://github.com/Security-Onion-Solutions/securityonion.git
synced 2026-05-08 20:38:00 +02:00
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.
This commit is contained in:
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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
|
||||
-- '*_<rolename>' 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();
|
||||
@@ -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": "<armored ciphertext>"} 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;
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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
|
||||
$$;
|
||||
@@ -8,6 +8,7 @@
|
||||
include:
|
||||
{% if PGMERGED.enabled %}
|
||||
- postgres.enabled
|
||||
- postgres.schema_pillar
|
||||
{% else %}
|
||||
- postgres.disabled
|
||||
{% endif %}
|
||||
|
||||
@@ -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 <<EOSQL
|
||||
ALTER ROLE so_pillar_secret_owner SET so_pillar.master_key = '$KEY';
|
||||
ALTER ROLE so_pillar_master SET so_pillar.master_key = '$KEY';
|
||||
ALTER ROLE so_pillar_writer SET so_pillar.master_key = '$KEY';
|
||||
EOSQL
|
||||
else
|
||||
echo "so_pillar.key not present yet; setup/so-functions must generate it before schema_pillar.sls" >&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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user