mirror of
https://github.com/Security-Onion-Solutions/securityonion.git
synced 2026-05-09 04:42:40 +02:00
3fad895d6a
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.
125 lines
5.5 KiB
SQL
125 lines
5.5 KiB
SQL
-- 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);
|