mirror of
https://github.com/Security-Onion-Solutions/securityonion.git
synced 2026-06-12 21:29:16 +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,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;
|
||||
Reference in New Issue
Block a user