Add PostgreSQL reporting schema and ingest contract
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 25s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled

This commit is contained in:
OfficeCom Codex
2026-07-25 01:41:13 +02:00
parent f9f4d862bf
commit 335410b418
3 changed files with 141 additions and 0 deletions

View File

@@ -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
<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
- The client only knows its outward upload destination.

View 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
View 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();
```