From 335410b418a032f15d3af32cf433c3f5b630ac9e Mon Sep 17 00:00:00 2001 From: OfficeCom Codex Date: Sat, 25 Jul 2026 01:41:13 +0200 Subject: [PATCH] Add PostgreSQL reporting schema and ingest contract --- docs/ocsentinel-n8n-contract.md | 30 ++++++++++++ infra/postgres/001_ocsentinel.sql | 81 +++++++++++++++++++++++++++++++ infra/postgres/README.md | 30 ++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 infra/postgres/001_ocsentinel.sql create mode 100644 infra/postgres/README.md diff --git a/docs/ocsentinel-n8n-contract.md b/docs/ocsentinel-n8n-contract.md index 9d163a4..d8e9c86 100644 --- a/docs/ocsentinel-n8n-contract.md +++ b/docs/ocsentinel-n8n-contract.md @@ -26,6 +26,36 @@ n8n is responsible for: - organization-wide aggregation - NinjaOne organization API updates +## Required n8n Workflow + +The webhook itself may be reachable only on the internal network. It does not +require public access to the n8n editor or API. Every managed device must be +able to reach the webhook URL over HTTPS. + +1. `Webhook`: accept `POST` on the configured private URL. +2. `Code`: reject a request if `X-ATN-Device`, `X-ATN-Timestamp`, + `X-ATN-Nonce`, `X-ATN-Version`, `X-ATN-Payload-SHA256`, or + `X-ATN-Signature` is missing; reject timestamps outside five minutes. +3. `Code`: calculate SHA-256 over the raw request body and compare it with + `X-ATN-Payload-SHA256`. Calculate HMAC-SHA256 over the following exact + newline-separated string and compare it in constant time with + `X-ATN-Signature`: + +```text +\n\n\n\n +``` + +4. `Postgres`: insert the nonce into `ocsentinel.ingest_nonce` with a short + expiry. If it already exists, return `409` and do not process the report. +5. `Postgres`: upsert the device, insert a row in `ocsentinel.scan_report`, + then return `202`. +6. A separate scheduled n8n workflow reads + `ocsentinel.organization_summary` and `ocsentinel.current_device_status` + to update the NinjaOne organization fields through the API. + +Use an n8n credential for the shared HMAC secret and a separate n8n credential +for PostgreSQL. Do not store either value in workflow JSON or this repository. + ## PostgreSQL Scope - The client only knows its outward upload destination. diff --git a/infra/postgres/001_ocsentinel.sql b/infra/postgres/001_ocsentinel.sql new file mode 100644 index 0000000..2ef60cf --- /dev/null +++ b/infra/postgres/001_ocsentinel.sql @@ -0,0 +1,81 @@ +-- OfficeCom Sentinel central reporting store. +-- Apply once as a PostgreSQL administrator to the dedicated ocsentinel database. + +BEGIN; + +CREATE SCHEMA IF NOT EXISTS ocsentinel; + +CREATE TABLE IF NOT EXISTS ocsentinel.device ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + machine_name TEXT NOT NULL, + machine_name_key TEXT NOT NULL UNIQUE, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_client_version TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS ocsentinel.scan_report ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + device_id BIGINT NOT NULL REFERENCES ocsentinel.device(id) ON DELETE CASCADE, + generated_at_utc TIMESTAMPTZ NOT NULL, + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + client_version TEXT NOT NULL, + alert_state TEXT NOT NULL CHECK (alert_state IN ('ok', 'warning', 'critical', 'unknown')), + base_alert_state TEXT NOT NULL CHECK (base_alert_state IN ('ok', 'warning', 'critical', 'unknown')), + total_events INTEGER NOT NULL CHECK (total_events >= 0), + unique_ip_count INTEGER NOT NULL CHECK (unique_ip_count >= 0), + cve_total INTEGER NOT NULL DEFAULT 0 CHECK (cve_total >= 0), + cve_critical INTEGER NOT NULL DEFAULT 0 CHECK (cve_critical >= 0), + payload_sha256 CHAR(64) NOT NULL, + payload JSONB NOT NULL, + UNIQUE (device_id, generated_at_utc, payload_sha256) +); + +CREATE INDEX IF NOT EXISTS ix_ocsentinel_scan_report_device_received + ON ocsentinel.scan_report (device_id, received_at DESC); + +CREATE INDEX IF NOT EXISTS ix_ocsentinel_scan_report_alert_received + ON ocsentinel.scan_report (alert_state, received_at DESC); + +CREATE TABLE IF NOT EXISTS ocsentinel.ingest_nonce ( + nonce CHAR(32) PRIMARY KEY, + device_id BIGINT NOT NULL REFERENCES ocsentinel.device(id) ON DELETE CASCADE, + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS ix_ocsentinel_ingest_nonce_expires + ON ocsentinel.ingest_nonce (expires_at); + +CREATE OR REPLACE VIEW ocsentinel.current_device_status AS +SELECT DISTINCT ON (d.id) + d.machine_name, + d.first_seen_at, + d.last_seen_at, + d.last_client_version, + r.generated_at_utc, + r.received_at, + r.alert_state, + r.base_alert_state, + r.total_events, + r.unique_ip_count, + r.cve_total, + r.cve_critical, + r.payload +FROM ocsentinel.device AS d +LEFT JOIN ocsentinel.scan_report AS r ON r.device_id = d.id +ORDER BY d.id, r.generated_at_utc DESC NULLS LAST, r.received_at DESC NULLS LAST; + +CREATE OR REPLACE VIEW ocsentinel.organization_summary AS +SELECT + count(*) FILTER (WHERE generated_at_utc IS NOT NULL) AS devices_reporting, + count(*) FILTER (WHERE alert_state = 'warning') AS devices_warning, + count(*) FILTER (WHERE alert_state = 'critical') AS devices_critical, + coalesce(sum(total_events), 0) AS total_events, + coalesce(sum(unique_ip_count), 0) AS total_unique_ips, + coalesce(sum(cve_total), 0) AS total_cves, + coalesce(sum(cve_critical), 0) AS critical_cves, + max(received_at) AS last_report_received_at +FROM ocsentinel.current_device_status; + +COMMIT; diff --git a/infra/postgres/README.md b/infra/postgres/README.md new file mode 100644 index 0000000..ea9c0ca --- /dev/null +++ b/infra/postgres/README.md @@ -0,0 +1,30 @@ +# OfficeCom Sentinel PostgreSQL + +PostgreSQL is the private central store for endpoint reports. It is never +contacted directly by an endpoint; only n8n uses a database account. + +## Provisioning + +1. Create a database named `ocsentinel` on the existing private PostgreSQL server. +2. Apply `001_ocsentinel.sql` as a database administrator. +3. Create a non-superuser n8n login and grant only the necessary permissions: + +```sql +GRANT USAGE ON SCHEMA ocsentinel TO ocsentinel_n8n; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ocsentinel TO ocsentinel_n8n; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ocsentinel TO ocsentinel_n8n; +GRANT SELECT ON ocsentinel.current_device_status, ocsentinel.organization_summary TO ocsentinel_n8n; +ALTER DEFAULT PRIVILEGES IN SCHEMA ocsentinel + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ocsentinel_n8n; +``` + +Keep the database host, password, and TLS settings only in n8n credentials. +They do not belong in Gitea, NinjaOne scripts, or endpoint configuration. + +## Maintenance + +Run monthly from n8n or an administrator session to remove expired replay tokens: + +```sql +DELETE FROM ocsentinel.ingest_nonce WHERE expires_at < now(); +```