Add PostgreSQL reporting schema and ingest contract
This commit is contained in:
@@ -26,6 +26,36 @@ n8n is responsible for:
|
|||||||
- organization-wide aggregation
|
- organization-wide aggregation
|
||||||
- NinjaOne organization API updates
|
- 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
|
||||||
|
<device>\n<timestamp>\n<nonce>\n<version>\n<payload-sha256>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## PostgreSQL Scope
|
||||||
|
|
||||||
- The client only knows its outward upload destination.
|
- The client only knows its outward upload destination.
|
||||||
|
|||||||
81
infra/postgres/001_ocsentinel.sql
Normal file
81
infra/postgres/001_ocsentinel.sql
Normal file
@@ -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;
|
||||||
30
infra/postgres/README.md
Normal file
30
infra/postgres/README.md
Normal file
@@ -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();
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user