From db2d38d6d95288ba473e8ef184ca90800d223a8c Mon Sep 17 00:00:00 2001 From: Blagovest Petrov Date: Thu, 29 Oct 2026 00:13:00 +0200 Subject: [PATCH 1/2] feat(reports): compliance reports UI and extension hooks Add the web UI for compliance reports, a Bor Enterprise feature, and the generic extension hooks an enterprise module needs to provide it. The community server contains no report implementation; the pages are shown only when the server advertises the reports edition feature, and a notice explains the feature otherwise. The live Compliance page is unchanged and stays in every edition. Edition and extension API (pkg/edition, pkg/ext, internal/exthost): - edition.ComplianceReports ("reports") - HandleUser for routes that authorize per request, with Authorize and DenyOutOfScope returning the caller's node-group scope - RegisterScopableResource; authz scopable resources are now registrable - Agents().Resync to ask an agent for a fresh policy sync - RegisterComplianceListener, fed by the compliance upsert, which now returns the previous status so listeners see changes only - SIEM() to format and forward events through the configured syslog sinks (syslog ForwardRaw and Format) - PKI() with pki.IssueDocumentSigningCert: certificates with the id-kp-documentSigning usage (RFC 9336) and no TLS usages - RegisterMetrics to add Prometheus collectors to /metrics; a collector the registry rejects is logged and left out Web UI: Fleet -> Reports with the report list, generate dialog, report detail (summary, nodes, results, controls, compare, sign-off, downloads), schedules and waivers tabs, scope fields, a compliance trend chart and a dashboard trend card, and report actions on the Compliance page. Extension settings panels tolerate tables without rows. The bundled frontend is rebuilt. Docs: reports user guide, server configuration, permissions, editions, compliance, audit log and monitoring pages. --- README.md | 2 + doc/configuration/server.md | 50 + doc/development/editions.md | 46 +- doc/operations/monitoring.md | 77 ++ doc/reference/permissions.md | 18 + doc/security/compliance.md | 6 +- doc/user-guide/audit-log.md | 1 + doc/user-guide/compliance.md | 5 + doc/user-guide/reports.md | 327 +++++++ doc/user-guide/web-ui.md | 1 + mkdocs.yml | 1 + server/internal/api/permission_gate.go | 46 + server/internal/audit/syslog.go | 88 +- server/internal/authz/authz.go | 16 + server/internal/database/dconf.go | 19 +- server/internal/exthost/exthost.go | 146 +++ server/internal/exthost/exthost_test.go | 17 + server/internal/grpc/server.go | 49 +- server/internal/metrics/server.go | 13 +- server/internal/metrics/server_test.go | 40 + server/internal/pki/pki.go | 43 + server/pkg/app/app.go | 26 +- server/pkg/edition/edition.go | 6 +- server/pkg/ext/ext.go | 141 +++ server/web/frontend/src/Shell.tsx | 11 +- .../web/frontend/src/apiClient/permissions.ts | 2 + .../web/frontend/src/apiClient/reportsApi.ts | 586 ++++++++++++ .../src/views/Compliance/CompliancePage.tsx | 48 +- .../Dashboard/ComplianceTrendSection.tsx | 60 ++ .../src/views/Dashboard/DashboardPage.tsx | 2 + .../src/views/Reports/EditionNotice.tsx | 23 + .../src/views/Reports/GenerateReportModal.tsx | 185 ++++ .../src/views/Reports/ReportDetailPage.tsx | 857 ++++++++++++++++++ .../src/views/Reports/ReportDetailTabs.tsx | 450 +++++++++ .../src/views/Reports/ReportsPage.tsx | 449 +++++++++ .../src/views/Reports/ScheduleModal.tsx | 340 +++++++ .../src/views/Reports/SchedulesTab.tsx | 275 ++++++ .../src/views/Reports/ScopeFields.tsx | 131 +++ .../frontend/src/views/Reports/TrendChart.tsx | 216 +++++ .../frontend/src/views/Reports/WaiversTab.tsx | 289 ++++++ .../web/frontend/src/views/Reports/index.ts | 7 + .../src/views/Reports/reportsShared.tsx | 163 ++++ .../src/views/Settings/ExtensionPanelTab.tsx | 2 +- server/web/static/bor.js | 2 +- 44 files changed, 5216 insertions(+), 66 deletions(-) create mode 100644 doc/user-guide/reports.md create mode 100644 server/internal/metrics/server_test.go create mode 100644 server/web/frontend/src/apiClient/reportsApi.ts create mode 100644 server/web/frontend/src/views/Dashboard/ComplianceTrendSection.tsx create mode 100644 server/web/frontend/src/views/Reports/EditionNotice.tsx create mode 100644 server/web/frontend/src/views/Reports/GenerateReportModal.tsx create mode 100644 server/web/frontend/src/views/Reports/ReportDetailPage.tsx create mode 100644 server/web/frontend/src/views/Reports/ReportDetailTabs.tsx create mode 100644 server/web/frontend/src/views/Reports/ReportsPage.tsx create mode 100644 server/web/frontend/src/views/Reports/ScheduleModal.tsx create mode 100644 server/web/frontend/src/views/Reports/SchedulesTab.tsx create mode 100644 server/web/frontend/src/views/Reports/ScopeFields.tsx create mode 100644 server/web/frontend/src/views/Reports/TrendChart.tsx create mode 100644 server/web/frontend/src/views/Reports/WaiversTab.tsx create mode 100644 server/web/frontend/src/views/Reports/index.ts create mode 100644 server/web/frontend/src/views/Reports/reportsShared.tsx diff --git a/README.md b/README.md index 4c85773a..9a221080 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ can do. restores it when someone changes it locally, and reports the event. - **Compliance reporting.** Every machine reports, per policy, whether it is compliant, and the web interface shows the state of the whole fleet. + Bor Enterprise adds point-in-time compliance reports that freeze that + state as signed evidence for auditors. - **Simple rollout.** Each server hosts signed agent package repositories and a deploy wizard, so agents install and enroll with one script and managed desktops need no internet access. diff --git a/doc/configuration/server.md b/doc/configuration/server.md index 866513bb..40d39cef 100644 --- a/doc/configuration/server.md +++ b/doc/configuration/server.md @@ -392,6 +392,53 @@ sudo chmod 0600 /etc/bor/escrow.kek up separately from the database backup. See [Backup and restore](../operations/backup.md). +## reports + +Compliance reports: point-in-time snapshots of the fleet's compliance +results and posture, with schedules, delivery, waivers and sign-off. See +[Compliance reports](../user-guide/reports.md). Compliance reports are +implemented by the Bor Enterprise server; the community server ignores this +section. Every key can also be set with its environment variable, which +wins over the file, so the section can be configured from the environment +alone. **Settings -> Compliance reports** shows the settings in force +(read-only) and sends a test email. Reports are stored in PostgreSQL (tables `ext_reports_*`) and are +part of the normal database backup. + +| YAML key | Environment variable | Default | Description | +|----------|----------------------|---------|-------------| +| `reports.retention_days` | `BOR_REPORT_RETENTION_DAYS` | `1095` | Days a report is kept. An hourly job deletes older unpinned reports and records a `report.purge` audit event. `0` keeps reports forever. | +| `reports.stale_after` | `BOR_REPORT_STALE_AFTER` | `168h` | Age after which a compliance result counts as stale and a node that is not online counts as unreachable in a report. A Go duration such as `72h`. | +| `reports.max_results` | `BOR_REPORT_MAX_RESULTS` | `100000` | A report with more node-policy results than this is refused; narrow its scope. | +| `reports.generate_timeout` | `BOR_REPORT_GENERATE_TIMEOUT` | `120s` | Time budget of one generation, including a fresh-evidence wait. | +| `reports.daily_summary` | `BOR_REPORT_DAILY_SUMMARY` | `true` | Store one summary (counts only) per day for the fleet and for every node group; they feed the compliance trend. | +| `reports.refresh_wait` | `BOR_REPORT_REFRESH_WAIT` | `60s` | How long a fresh-evidence report waits for the agents to re-check. Bounded by `generate_timeout`. | +| `reports.refresh_rate` | `BOR_REPORT_REFRESH_RATE` | `50` | Agents per second a fresh-evidence report asks to re-check. | +| `reports.public_url` | `BOR_REPORT_PUBLIC_URL` | (none) | The web UI's address, `https://...`; deliveries link to reports under it. Without it, webhook payloads and emails carry no links. | +| `reports.timestamp_url` | `BOR_REPORT_TIMESTAMP_URL` | (none) | An RFC 3161 time-stamping authority that time-stamps the PDF signature. Leave it empty on air-gapped installations; a PDF that cannot be time-stamped is not produced. | +| `reports.smtp.host` | `BOR_REPORT_SMTP_HOST` | (none) | Mail relay for email delivery; empty disables email. | +| `reports.smtp.port` | `BOR_REPORT_SMTP_PORT` | `587`, or `465` with `tls: implicit` | Relay port. | +| `reports.smtp.tls` | `BOR_REPORT_SMTP_TLS` | `starttls` | `starttls` (the relay must offer it) or `implicit`. Mail is never sent in the clear. | +| `reports.smtp.username` | `BOR_REPORT_SMTP_USERNAME` | (none) | Login for the relay (SMTP AUTH PLAIN over TLS). | +| `reports.smtp.password_file` / `password_env` | `BOR_REPORT_SMTP_PASSWORD_FILE`, `BOR_REPORT_SMTP_PASSWORD` | (none) | The relay password: read from a file, or from the environment variable `password_env` names. `BOR_REPORT_SMTP_PASSWORD` gives the password itself (for example from a container secret) and wins over every other source. Never in `server.yaml`. | +| `reports.smtp.ca_file` | `BOR_REPORT_SMTP_CA_FILE` | (system CAs) | PEM file of the certificate authorities the relay's certificate is checked against, for a relay with a certificate from a private CA. | +| `reports.smtp.from` | `BOR_REPORT_SMTP_FROM` | (none) | Sender address, for example `Bor `. Required with `host`. | +| `reports.allow_http_webhooks` | `BOR_REPORT_ALLOW_HTTP_WEBHOOKS` | `false` | Allow `http://` webhook URLs. Keep it off outside test environments. | +| `reports.ocsf_findings` | `BOR_REPORT_OCSF_FINDINGS` | `true` | Forward compliance status changes as OCSF Compliance Findings when the audit log is forwarded in OCSF format. | +| `reports.required_approvals` | `BOR_REPORT_REQUIRED_APPROVALS` | `1` | Approvals (by people other than the generator) a report needs to count as approved; 1 to 5. | +| `reports.waiver_max_days` | `BOR_REPORT_WAIVER_MAX_DAYS` | `365` | Longest lifetime of a waiver. | +| `reports.catch_up_window` | `BOR_REPORT_CATCH_UP_WINDOW` | `24h` | A schedule run missed while the server was down runs at startup when it is younger than this; older runs are skipped. | + +```yaml +reports: + retention_days: 1095 + public_url: "https://bor.example.com" + smtp: + host: "smtp.example.com" + username: "bor" + password_file: "/etc/bor/smtp-password" + from: "Bor " +``` + ## Systemd options that are not configuration keys Some behavior is controlled through the service environment rather than @@ -458,4 +505,7 @@ audit: disk_encryption: escrow_kek_file: "/etc/bor/escrow.kek" +reports: + retention_days: 1095 + stale_after: 168h ``` diff --git a/doc/development/editions.md b/doc/development/editions.md index 75ce3e5a..d916a851 100644 --- a/doc/development/editions.md +++ b/doc/development/editions.md @@ -89,7 +89,7 @@ expires) without a restart. ### Features `Feature` is a string type. The known features are listed in -`edition.AllFeatures`; there is currently one: +`edition.AllFeatures`; there are currently two: `NodeGroupScopedRBAC` (`"node_group_scoped_rbac"`) : Role bindings scoped to a single node group (delegated @@ -103,6 +103,14 @@ expires) without a restart. nodeGroupScopes := func() bool { return ed.Enabled(edition.NodeGroupScopedRBAC) } ``` +`ComplianceReports` (`"reports"`) +: Point-in-time compliance reports. They are implemented entirely by the + enterprise `reports` extension module (storage, generation, API under + `/api/v1/ext/reports/`, signing, documents, schedules); the community + server contains only the web UI pages, which show the **Reports** page + while the edition enables the feature (the feature list of + `/api/v1/auth/me` drives it) and a Bor Enterprise notice otherwise. + To add a feature, declare a new `Feature` constant, append it to `AllFeatures`, and gate the behavior on `ed.Enabled(...)` at request time. @@ -227,8 +235,40 @@ Audit and secrets `RequestUser(r)` returns the signed-in user inside a `HandleAdmin` route. -The community edition registers no modules, so `/api/v1/ext-panels` -returns an empty list and the login page shows no extra buttons. +Permissions for user routes +: `HandleUser(pattern, h)` mounts a handler for any signed-in user; the + handler checks each operation with `Authorize(w, r, resource, action, + scoped)`, which returns where the user holds the permission (globally, + or the node groups of a scoped role binding) and answers 403 with an + audited denial otherwise. `RegisterScopableResource(resource)` lets + node-group-scoped role bindings grant the module's permissions; the + module must then confine every operation to the returned groups + (`DenyOutOfScope` refuses and audits an object outside them). + +Agents, compliance results and certificates +: `Agents().Resync(nodeName)` asks a connected agent to re-apply its + policies and report compliance again. + `RegisterComplianceListener(l)` tells the module about every + compliance result an agent reports, with the previous status, the + items and the node's operating system; the listener runs on the + agent's request and must return quickly. `SIEM()` forwards a + pre-formatted message with the audit log's syslog forwarding (and + reports its format, `ocsf` or `cef`, or none). `PKI()` gives access to + the server's certificate authority: `CACertificatePEM()` and + `IssueCertificate(ctx, ext.CertificateRequest{...})`, which today + issues document-signing certificates (extended key usage + id-kp-documentSigning, no TLS usages) for a public key the module + generated and keeps itself. + +Metrics +: `RegisterMetrics(collector)`, called from `Init`, adds a Prometheus + collector to the server's `/metrics` endpoint. Metric names start with + `bor_`; a collector the registry rejects (an invalid or clashing + metric) is logged and left out without stopping the server. + +The community edition registers no modules and enables no features, so +`/api/v1/ext-panels` returns an empty list, the login page shows no extra +buttons and no compliance report routes exist. ## Building a distribution diff --git a/doc/operations/monitoring.md b/doc/operations/monitoring.md index 94442908..2e3dacfa 100644 --- a/doc/operations/monitoring.md +++ b/doc/operations/monitoring.md @@ -214,6 +214,40 @@ These metrics carry aggregate labels only, never node names or key material. : Recovery keys revealed to administrators. Each reveal is also recorded in the audit log. +### Compliance reports (Bor Enterprise) + +Served by servers whose license includes compliance reports. Like the +metrics above they are read from the database on every scrape. + +`bor_compliance_reports_total` (gauge, label `kind`) +: Stored reports: `full` reports and daily `summary` snapshots. + +`bor_compliance_report_last_generated_timestamp_seconds` (gauge, label `trigger`) +: Unix time of the newest stored report per trigger: `manual`, + `scheduled`, `daily_summary` or `api`. A trigger that never produced a + report has no series. + +`bor_compliance_score_pct` (gauge, label `scope`) +: Fleet compliance score in percent (`scope="fleet"`) from the newest + daily summary that covers all nodes. + +`bor_compliance_report_schedules` (gauge, label `state`) +: Report schedules by the result of their last run (`ok`, `error`, + `skipped`, `never_run`), with switched-off schedules as `disabled`. + +`bor_compliance_report_schedules_overdue` (gauge, no labels) +: Enabled schedules whose next run is more than 15 minutes late: the + scheduler is not running (for example the license has expired) or a + run hangs. + +`bor_compliance_report_deliveries` (gauge, label `status`) +: Webhook calls and emails in the delivery queue: `pending`, `sending`, + `sent` and `failed`. Finished deliveries are kept for 90 days. + +`bor_compliance_report_signing_certificate_expiry_timestamp_seconds` (gauge, no labels) +: Expiry of the active report-signing certificate. The server renews it + 30 days before; a value closer than that means renewal fails. + !!! note "Missing series mean a failed query" If a database query fails during a scrape, the server logs a line starting with `metrics:` (for example `metrics: CountByStatus: ...`) and simply @@ -351,6 +385,45 @@ groups: severity: warning annotations: summary: "Recovery-key escrow operation {{ $labels.op }} failed" + + # Bor Enterprise compliance reports + - alert: BorReportScheduleFailing + expr: bor_compliance_report_schedules{state="error"} > 0 + labels: + severity: warning + annotations: + summary: "{{ $value }} compliance report schedule(s) failed their last run" + + - alert: BorReportScheduleOverdue + expr: bor_compliance_report_schedules_overdue > 0 + for: 15m + labels: + severity: warning + annotations: + summary: "Compliance report schedules are not running" + + - alert: BorReportDeliveryFailed + expr: delta(bor_compliance_report_deliveries{status="failed"}[1h]) > 0 + labels: + severity: warning + annotations: + summary: "A compliance report delivery failed after all retries" + + - alert: BorReportSigningCertificateNotRenewed + expr: bor_compliance_report_signing_certificate_expiry_timestamp_seconds - time() < 20 * 86400 + for: 1h + labels: + severity: critical + annotations: + summary: "The compliance report signing certificate was not renewed" + + - alert: BorFleetComplianceLow + expr: bor_compliance_score_pct{scope="fleet"} < 90 + for: 1d + labels: + severity: info + annotations: + summary: "Fleet compliance score is {{ $value }}%" ``` Notes on these rules: @@ -365,6 +438,10 @@ Notes on these rules: - `BorFlatpakCatalogStale` only makes sense when catalog refresh is enabled (`flatpak_catalog.refresh_enabled`, on by default). Each repository's refresh interval is configured per repository; adjust the threshold to match. +- `BorReportDeliveryFailed` uses `delta` because the delivery counts are + gauges; the **Settings -> Compliance reports** page lists the failed + deliveries and retries them. Set the `BorFleetComplianceLow` threshold + to your own target. - Do not alert on `bor_node_last_seen_seconds` age: as explained above, it only changes when an agent reconnects. diff --git a/doc/reference/permissions.md b/doc/reference/permissions.md index 90309729..abf1eabb 100644 --- a/doc/reference/permissions.md +++ b/doc/reference/permissions.md @@ -115,6 +115,18 @@ hold yourself. |---|---| | `updates:operate` | Run a Software updates action on one node from the **Software updates** section of the node details: refresh the package metadata, run the update engine once with the policy's mode, or reboot the node (forced reboots warn logged-in users first). The action runs on the node at once and is audit-logged (`updates.action`) with the outcome. Can be delegated per node group. | +### Compliance reports + +These permissions exist on Bor Enterprise servers, which create them +together with the report tables; a community server has neither. + +| Permission | Allows | +|---|---| +| `report:view` | List, open and download compliance reports (the **Reports** page). Downloads are audit-logged (`report.download`). Can be delegated per node group: a scoped holder sees only reports whose scope lies inside their groups. | +| `report:create` | Generate a compliance report. Audit-logged (`report.generate`) with the report's hash. Can be delegated per node group: a scoped holder generates reports over their groups only. | +| `report:manage` | Delete compliance reports (`report.delete`), pin and unpin them (legal hold, `report.pin`, `report.unpin`), and create, change, run and delete report schedules. Global only. | +| `report:attest` | Sign a compliance report off (approve or reject it, `report.attest`) when someone else generated it, and accept and revoke waivers (`report_waiver.create`, `report_waiver.revoke`). Global only. | + ## Built-in roles `Y` means the role holds the permission on a new installation. @@ -130,6 +142,10 @@ hold yourself. | `binding:create` | Y | Y | | | | | | `binding:toggle` | Y | Y | | | | | | `compliance:view` | Y | Y | | | Y | Y | +| `report:view` | Y | Y | | | Y | Y | +| `report:create` | Y | Y | | | | Y | +| `report:manage` | Y | | | | | | +| `report:attest` | Y | | | | | Y | | `node:view` | Y | Y | | | | Y | | `node:create` | Y | Y | | | | | | `node:edit` | Y | Y | | | | | @@ -181,6 +197,8 @@ checked, the **Policy Editor** role can release policies (it holds ## Navigation and permissions The sidebar hides **Disk Encryption** without `disk_encryption:view`, +**Reports** without `report:view` or when the edition does not enable +compliance reports, **Audit Logs** without `audit_log:view`, and **Settings** unless you hold at least one of `user:view`, `role:view`, `user_group:view`, `settings:manage` or `flatpak_repo:view`. Other pages are always listed diff --git a/doc/security/compliance.md b/doc/security/compliance.md index d0284a63..f707f706 100644 --- a/doc/security/compliance.md +++ b/doc/security/compliance.md @@ -287,6 +287,7 @@ Bor does not do or only partly does. | (b) Incident handling | Audit log of sign-ins, failures, permission denials, changes, tamper detections and recovery-key reveals; CEF / OCSF syslog export | Forward to a SIEM ([Audit log](../user-guide/audit-log.md)) | Successful requests only for generic API changes; failed changes appear only as permission denials or not at all | | (c) Business continuity | Documented backup and restore | Follow [Backup and restore](../operations/backup.md) | No built-in high availability or automated backups | | (d) Supply chain security | Signed agent repositories, pinned Go module versions (`go.sum`), weekly Dependabot updates for Go, npm and GitHub Actions | Verify the repository signing key on endpoints | No SBOM, no build provenance attestation, no signatures on individual packages | +| (f) Assessing the effectiveness of measures | Compliance reports (Bor Enterprise): signed point-in-time evidence of enforcement per node, mapped to Art. 21(2) and ISO/IEC 27001:2022 Annex A, with schedules, trend, comparison, waivers and sign-off ([Compliance reports](../user-guide/reports.md)) | Schedule reports for the scopes your audits cover; keep the bundles and signatures | Evidence covers what Bor enforces and the posture agents report, not a full benchmark scan | | (e) Vulnerability handling and disclosure | Private disclosure channel (see [Vulnerability reporting](#vulnerability-reporting)) | Subscribe to GitHub releases | No published CVE / advisory feed | | (h) Cryptography and encryption | See [Cryptographic inventory](#cryptographic-inventory) | Enable FIPS mode if required; use TLS for PostgreSQL and LDAP | Database at-rest encryption is the operator's responsibility | | (i) Access control and asset management | RBAC with granular permissions; node inventory; certificate-based agent identity; revocation | Assign least-privilege roles ([Permissions](../reference/permissions.md)) | Node-group-scoped roles are not available in the community edition | @@ -307,7 +308,7 @@ Bor does not do or only partly does. | Principle / right | How Bor supports it | Operator action | Gaps and limitations | |---|---|---|---| | Data minimization (Art. 5(1)(c)) | Optional truncation of client IP addresses in audit records to /24 (IPv4) or /48 (IPv6) | Set `BOR_AUDIT_ANONYMIZE_IPS=true` in the **environment** (the `audit.anonymize_ips` YAML key is not read) | Sign-in and logout audit events always store the full IP address; node inventory stores endpoint IP addresses; tamper events store local usernames of processes | -| Storage limitation (Art. 5(1)(e)) | Audit retention, default 365 days, purged at startup and daily; retired recovery keys kept 30 days, orphaned 90 days | Set `audit.retention_days` / `BOR_AUDIT_RETENTION_DAYS` to your policy | Data forwarded to a SIEM follows the SIEM's retention | +| Storage limitation (Art. 5(1)(e)) | Audit retention, default 365 days, purged at startup and daily; retired recovery keys kept 30 days, orphaned 90 days; compliance reports (Bor Enterprise) kept 1095 days, purged hourly | Set `audit.retention_days` / `BOR_AUDIT_RETENTION_DAYS` and `reports.retention_days` / `BOR_REPORT_RETENTION_DAYS` to your policy | Data forwarded to a SIEM follows the SIEM's retention; downloaded report bundles follow the downloader's retention | | Right of access / portability (Art. 15, 20) | `GET /api/v1/users/me/export` returns the signed-in user's account, MFA status and security keys as JSON | None | Audit records about the user are not included | | Right to erasure (Art. 17) | Deleting a user removes the account, MFA data, security keys and role assignments | Delete the user; purge or pseudonymize audit entries in the database if required | Audit entries keep the username (only the user ID link is cleared); no built-in pseudonymization of audit history | | Transparency | Privacy policy link in the UI (`ui.privacy_policy_url`) | Publish and link your privacy notice | - | @@ -371,7 +372,7 @@ over TCP with `sslmode: disable`. For PostgreSQL over TCP use | Aspect | Detail | |---|---| -| Events | Successful (`2xx`) `POST`/`PUT`/`PATCH`/`DELETE` API requests; successful and failed sign-ins, logouts, MFA enrollment and removal, step-up; permission denials (`access_denied`); role removals; agent tamper detections (`tamper_detected`); policy export and import; recovery-key escrow, reveal, refused reveal, rotation and destruction (`recoverykey.*`, `luks.*`) | +| Events | Successful (`2xx`) `POST`/`PUT`/`PATCH`/`DELETE` API requests; successful and failed sign-ins, logouts, MFA enrollment and removal, step-up; permission denials (`access_denied`); role removals; agent tamper detections (`tamper_detected`); policy export and import; recovery-key escrow, reveal, refused reveal, rotation and destruction (`recoverykey.*`, `luks.*`); compliance report generation, download, deletion, pinning, sign-off, delivery, retention purge and integrity failures (`report.*`, with the report's SHA-256), and waivers (`report_waiver.*`) | | Fields | Event time, actor (user ID and username, or node for agent events), action, resource type and ID, outcome, source IP address, details (redacted request body, tamper file path and processes with PID, command and user) | | Storage | PostgreSQL `audit_logs` | | Viewing and export | **Audit Logs** page (`audit_log:view`); **Export CSV** / **Export JSON** (`audit_log:export`) | @@ -419,6 +420,7 @@ risk assessment. | Audit source IP | The audit log takes the client IP from `X-Forwarded-For` or `X-Real-IP` when present, without checking for a trusted proxy, so a client can set it. Rate limiting uses the TCP peer address and is not affected. | Treat audit IP addresses as informational, or strip these headers at a trusted reverse proxy. | | IP anonymization coverage | `BOR_AUDIT_ANONYMIZE_IPS` does not apply to sign-in and logout events. | Account for this in your records of processing. | | Audit log integrity | No hash chain or signatures. | Forward events to a SIEM in real time. | +| Compliance report integrity (Bor Enterprise) | A report's SHA-256 is re-checked and its signature verified on every open, but anyone with write access to the database can replace a bundle together with its hash; only the signature, the forwarded audit event and downloaded copies survive that. | Forward the audit log (the `report.generate` event carries the hash) to a SIEM or write-once store; keep downloaded bundles and signatures outside the server and verify them with `bor-verify`. | | Backup codes | Hashed with bcrypt at cost 10 (not a FIPS-approved algorithm); 40 bits of entropy per code. | Rate limiting applies to the sign-in endpoints. | | `BOR_MFA_SECRET` | No minimum length is enforced; when unset, `jwt_secret` is used, so changing `jwt_secret` breaks existing TOTP enrollments. | Set a dedicated 32-byte random value before users enroll. | | WebAuthn | No attestation, user verification only preferred. | Combine with a password (always required) and enforce MFA. | diff --git a/doc/user-guide/audit-log.md b/doc/user-guide/audit-log.md index afa0654a..0518a75b 100644 --- a/doc/user-guide/audit-log.md +++ b/doc/user-guide/audit-log.md @@ -19,6 +19,7 @@ requires `audit_log:view`; exporting it requires `audit_log:export`. | Authentication | Successful and failed sign-ins, logouts, MFA enrollment and removal, re-authentication for privileged actions. | `auth.login`, `auth.logout`, `auth.mfa.enroll`, `auth.mfa.disable`, ... | | Authorization | A request refused because the user lacks a permission, including privilege-escalation attempts. | `access_denied` | | Role removals | Role assignments removed. | `revoke_role` | +| Compliance reports | A compliance report was generated, downloaded, deleted or purged by retention, or its stored bundle no longer matched its hash. Generation and deletion events carry the report's SHA-256. | `report.generate`, `report.download`, `report.delete`, `report.purge`, `report.integrity_failure` on `compliance_report` | | Tampering | An agent detected and reverted a local change to a file it manages. The event names the node, the file and the processes that held the file. | `tamper_detected` on `managed_file` | | Unit drift | A reconcile pass of the Services & timers policy found a managed systemd unit stopped, started, enabled, disabled or masked by hand and put it back. The event names the node, the unit (`unit:bluetooth.service`), what was found and done, and, when the journal has a `sudo` or polkit record naming the unit, who did it. | `tamper_detected` on `systemd_unit` | | Unit actions | An operator started, stopped or restarted a unit on a node from the node details. The event names the node, the unit, the action and the outcome the agent reported (`success`, `failed`, `not_connected`, `timeout`). | `systemd.unit_action` on `node` | diff --git a/doc/user-guide/compliance.md b/doc/user-guide/compliance.md index c5180b5c..8cc888d3 100644 --- a/doc/user-guide/compliance.md +++ b/doc/user-guide/compliance.md @@ -8,6 +8,11 @@ troubleshoot a non-compliant result. Open it from **Fleet -> Compliance** in the sidebar. Viewing compliance requires `compliance:view`. +The page shows the current state only. Bor Enterprise adds +[compliance reports](reports.md): point-in-time snapshots of the results, +the nodes' posture and the benchmark controls, hashed, signed and +downloadable as JSON, CSV and HTML. + ## How agents report compliance After the agent applies a policy type (on startup, when it receives a diff --git a/doc/user-guide/reports.md b/doc/user-guide/reports.md new file mode 100644 index 00000000..6297278c --- /dev/null +++ b/doc/user-guide/reports.md @@ -0,0 +1,327 @@ +# Compliance reports + +A compliance report is a point-in-time record of the fleet: which nodes +were in scope, which policies applied to each of them, the agent's last +compliance result for every policy, and each node's security posture +(disk encryption, pending security updates, mandatory access control). +The live [Compliance](compliance.md) page changes with every agent +report; a compliance report never changes after it is generated. Use it +to answer "were the lab machines encrypted and locked down on the 14th" +when an auditor asks, and to keep evidence for your records. + +Open **Fleet -> Reports** in the sidebar. The page shows the fleet's +compliance trend, lists reports and generates new ones; with the +corresponding permissions it also has the **Schedules** and **Waivers** +tabs. + +!!! note "Bor Enterprise" + Compliance reports are a Bor Enterprise feature: the Bor Enterprise + server generates, stores and serves them while its license includes the + `reports` feature. The community server has no reports; its + **Reports** page says so. Moving from the community server to Bor + Enterprise of the same version needs no migration step: install the + Bor Enterprise server and its license, and it creates the report tables + on its first start. Reports stay in the database when the license + lapses and reappear when it is renewed. + +## Permissions + +| Action | Permission | +|---|---| +| List, open and download reports; see the trend | `report:view` | +| Generate a report | `report:create` | +| Delete, pin and unpin reports; manage schedules | `report:manage` (global) | +| Sign a report off; accept and revoke waivers | `report:attest` (global) | + +A delegated administrator with a node-group-scoped role sees only reports +whose scope lies inside their groups and can generate reports only over +those groups; "all nodes" means all of their groups. The fleet trend, +schedules, waivers and sign-off need the permission globally. See +[Permissions](../reference/permissions.md). + +## Generate a report + +Select **Generate report**, choose the scope and submit: + +| Field | Meaning | +|---|---| +| **Scope** | **All nodes**, or one or more node groups. A node in several selected groups appears once. | +| **Policy types** | Limit the results to these policy types. Empty means every type. The nodes stay in scope either way, so a report limited to disk encryption still lists every node and says which ones had no result. | +| **Label** and **Notes** | Free text stored with the report, for example the audit it was made for. Both appear in the downloads. | +| **Include per-setting items** | On by default. Off stores only the per-policy result and message, without the per-setting breakdown. Switch it off when item keys would carry personal data you do not want in the evidence (for example SELinux login mappings of named users). | +| **Collect fresh evidence first** | Off by default. The server asks every online agent in scope to re-apply its policies and report again, waits until each of them has reported every result in scope anew (up to `reports.refresh_wait`, one minute by default), then takes the snapshot. The report records when the request went out, how long the server waited and how many agents answered in time; agents that did not answer keep their last result, which counts as stale if it is old. | + +Without fresh evidence the report is generated at once from the results +the server holds. A result is included only while an enabled binding +still delivers the policy to the node, the same rule the Compliance page +applies. The report records how old every result was and flags stale +ones (see below). + +Generation refuses a scope with more than `reports.max_results` +node-policy results (100000 by default); narrow the scope or raise the +limit in the [server configuration](../configuration/server.md#reports). + +## What a report contains + +| Section | Content | +|---|---| +| **Summary** | Nodes in scope, online, unreachable and without results; results, stale results and waived results; counts per status; the compliance score. | +| **Policies** | Every policy with a result: name, type, the policy version in force, and counts per status. | +| **Nodes** | Every node in scope: operating system, agent version, connection state and last-seen time, node groups, mandatory access control module and mode, whether the system volume is encrypted and its recovery key escrowed, pending security updates and reboot state, and counts per status. | +| **Results** | One entry per node and policy: status, the agent's message, when it was reported, its age at generation time, the framework controls it maps to, the waiver that covers it, and the per-setting items with their controls (when items are included). | +| **Controls** | Results and items aggregated by the framework controls they map to (see [Frameworks](#frameworks)), with pass, fail, error and inapplicable counts and the number of failing nodes. | +| **Waivers** | The waivers in force at generation time that cover at least one failure in the report. | +| **Collection** | For fresh-evidence reports: when the agents were asked, how long the server waited and how many answered. | + +**Score.** Compliant results divided by the results that could be +evaluated (compliant, non-compliant and error), in percent. Inapplicable +and unknown results do not count. A report with nothing to evaluate has +no score. Waived results stay failures in the score: a waiver accepts a +risk, it does not remove it. + +**Stale and unreachable.** A result older than `reports.stale_after` +(7 days by default) at generation time is marked stale; a node that was +not online and had not been seen within the same period is marked +unreachable. Both are counted in the summary and shown on the report, so +old evidence is never presented as current. Agents re-report after every +sync and whenever a managed file or unit changes, so a stable node's +results can legitimately be days old; generate with fresh evidence when +that matters. + +## Frameworks + +Results and items map to the controls of these frameworks: + +| Framework | What maps to it | +|---|---| +| ISO/IEC 27001:2022 Annex A | Disk encryption (A.8.24, A.8.1), software updates (A.8.8), mandatory access control, units, dconf and KDE settings (A.8.9), packages, repositories and Flatpak (A.8.19), the host firewall (A.8.20), privilege rules (A.8.2), session access (A.5.15, A.8.5), browsers (A.8.23, A.8.1), screen locking (A.7.7), removable media (A.7.10, A.8.7), audit and logging services (A.8.15, A.8.16). | +| NIS2 Directive Art. 21(2) | The same measures under (b) incident handling, (e) vulnerability handling, (g) basic cyber hygiene, (h) cryptography and encryption and (i) access control. | +| CIS Red Hat Enterprise Linux 9 Benchmark v2.0.0 | On nodes that report Red Hat Enterprise Linux 9: updates, SELinux, services and client packages that must not be in use, GNOME screen lock and removable media, repositories, the firewall, rsyslog and auditd. | +| CIS Ubuntu Linux 24.04 LTS Benchmark v1.0.0 | On nodes that report Ubuntu 24.04: updates, AppArmor, services and client packages that must not be in use, GNOME screen lock and removable media, repositories, rsyslog and auditd. | + +A CIS control is counted only where the result is evidence for it: a +compliant `telnet` package result supports "Ensure telnet client is not +installed" only when the policy removes the package, a unit result +supports "Ensure ... services are not in use" only when the policy masks +the unit or disables and stops it, and "Ensure the SELinux mode is +enforcing" only when the policy asks for enforcing mode. Controls for +which no Bor setting provides evidence are not listed. + +Each report records the version of the mapping it was built with +(`server.frameworks_version` in the bundle) and keeps its controls when +the mapping changes later. + +## Open a report + +Select a report to open it. The header shows when and by whom it was +generated, the scope, whether the report is pinned and signed off, and +the report's integrity: + +| Field | Meaning | +|---|---| +| **SHA-256** | The hash of the report's canonical JSON bundle, recorded when the report was generated and written to the audit log. | +| **Hash** | `ok` when the stored bundle still has that hash. `mismatch` means the stored data changed after generation; the server records a `report.integrity_failure` audit event when this happens. | +| **Signature** | Every report generated while the feature is licensed is signed with a certificate issued by the server's certificate authority; the status is `valid`, `invalid` (the signature does not match the stored bundle) or `unverified` (signed, but this server cannot check it, for example after the license was removed). `unsigned` marks a report generated while signing was off. | + +The tabs: + +| Tab | Content | +|---|---| +| **Overview** | The notes and the policies with their counts. | +| **Controls** | The framework controls, one framework at a time. Expand a control to list the results and settings behind it. | +| **Nodes** | The nodes with their posture. | +| **Results** | The results, with filters for status and staleness. Expand a result to see its per-setting items; waived results and settings are marked. With `report:attest`, **Accept exception** records a waiver for a failing result or one of its settings. | +| **Compare** | What changed since an earlier report (the latest earlier report over the same scope by default): new failures, fixed results, results that still fail, and nodes and policies added to or removed from the scope. | +| **Sign-off** | The report's attestations; see [Sign-off](#sign-off). | + +## Download a report + +| Format | Content | +|---|---| +| **JSON** | The canonical bundle (`apiVersion: bor.report.v1`), byte for byte as stored. Its SHA-256 is the hash shown in the header. Keep this file: it is the evidence, and every other format is derived from it. | +| **CSV** | Four files: `results` (one row per node and policy), `items` (one row per setting), `nodes` (one row per node with posture columns) and `controls` (one row per framework control). Cells that start with a formula character are prefixed with an apostrophe so spreadsheets do not execute them. | +| **Signature** | The detached signature of the JSON bundle (a compact JWS), for signed reports. | +| **HTML** | A self-contained, printable document of the report with the hash and the signing key on every page. | +| **PDF** | An archival document (PDF/A-2b, fonts embedded) with the summary, framework controls, failures, policies, nodes, waivers and notes; the bundle's hash and the signer are in the footer of every page. The PDF is signed with the report signing key (a CMS signature PDF readers display) and, when `reports.timestamp_url` is set, time-stamped by that time-stamping authority. It is rendered on its first download and kept, so every later download returns the same file. | +| **OCSF** | One OCSF 1.3.0 Compliance Finding (class 2003) per setting, or per result without settings, as NDJSON, for import into a SIEM or data lake. | +| **Attestations** | The report's sign-offs: each signed statement with its signature, for offline verification. | + +Every download is recorded in the audit log (`report.download`) with the +format, since handing out evidence is itself an auditable event. + +### Verify a downloaded bundle + +Compute the SHA-256 of the JSON file and compare it with the hash shown +in the report header or recorded in the `report.generate` audit event: + +```bash +sha256sum compliance-report-q4-labs-20261020-1400.json +``` + +When the audit log is forwarded to a SIEM, the hash in the forwarded +event is an anchor outside Bor's database: a bundle that matches it is +the bundle the server generated at that time. Signed reports and their +sign-offs are verified offline with the `bor-verify` tool and the +server's CA certificate: + +```bash +bor-verify -bundle compliance-report-q4-labs-20261020-1400.json \ + -signature compliance-report-q4-labs-20261020-1400.jws \ + -attestations compliance-report-q4-labs-20261020-1400.attestations.json \ + -ca ca.crt +``` + +The tool prints the report's summary, the signer and the outcome of +every check, and exits with 0 when all of them pass. + +## Compliance trend + +Once a day the server stores a summary of the fleet and of every node +group: the counts and the score only, no evidence bundle. The summaries +feed the **Fleet compliance score** chart above the report list and the +**Compliance trend** card on the dashboard (last 30 days; hover or use +the arrow keys for the values of a day, or show the chart as a table). +Daily summaries are hidden from the report list unless **Show daily +summaries** is on. Switch them off with `reports.daily_summary: false`. + +## Schedules + +The **Schedules** tab (`report:manage`) generates reports on a fixed +cadence: daily, weekly on a weekday, or monthly on a day of the month +(1-28, or the last day), at a time of day in a time zone. Runs follow +daylight saving time in that zone. A schedule has the same scope options +as a manual report, including fresh evidence, and a label template: +`{name}`, `{date}` and `{datetime}` are replaced with the schedule's name +and the run's local date and time. + +The server checks for due schedules every minute. A run missed while the +server was down runs once when it starts again, unless it is older than +`reports.catch_up_window` (24 hours by default); older runs +are recorded as skipped. **Run now** generates the schedule's report +immediately. The list shows the next run and the outcome of the last one +with a link to its report. Several servers sharing one database never +run the same schedule twice. + +Scheduled reports are generated by `schedule:`, signed and kept +like manual reports. + +### Delivery + +After each run a schedule can deliver the report: + +- **Webhook.** An HTTPS `POST` with a JSON body + (`event: report.generated`, the schedule, the report's summary, hash and + formats, and links to the report and its downloads when + `reports.public_url` is set). With a signing secret the request carries + `X-Bor-Timestamp` and + `X-Bor-Signature: sha256=.">`; verify + it before trusting the payload. `X-Bor-Delivery` identifies the delivery + and stays the same when it is retried, so a receiver can discard + duplicates. Redirects are not followed. +- **Email.** One message to all recipients with the summary and the + selected formats attached (PDF, HTML, JSON, CSV results, signature; at + most 20 MB in total), sent through the relay configured under + `reports.smtp` (or the `BOR_REPORT_SMTP_*` variables) over TLS only: + implicit TLS, or STARTTLS, which the relay must offer. + +A run puts its deliveries in a queue and finishes; a background worker +sends them. A delivery that fails is retried after 1, 5 and 15 minutes, +1 hour and 3 hours (six attempts in all); a refusal that cannot change +(an HTTP 4xx answer other than 429, an SMTP 5xx reply) ends it at once. +The queue is stored in the database, so deliveries survive a restart, and +several servers sharing one database never send one twice. The schedule +list shows the state of the last run's deliveries: **Queued**, +**Retrying** (with the last error), **Delivered** or **Delivery failed**. + +**Settings -> Compliance reports** (`settings:manage`) shows the mail and +webhook settings in force, where they were set, and the queue: deliveries +waiting, sent and failed in the last 24 hours, and the recent failures +with a **Retry** action. **Send test email** sends a message to your own +account's email address through the relay and shows the relay's answer. +Outcomes are recorded in the audit log (`report.delivered`, +`report.delivery_failed`, `report.delivery_retry`, `report.test_email`); +attachments are recorded as downloads by the schedule. + +## Waivers + +A waiver records that a failure is a known, accepted risk until a date: +for one policy, on one node or on every node, for the whole result or for +one setting (a key ending in `*` matches a prefix). Accept one from a +report's **Results** tab; the **Waivers** tab lists active waivers (or all +of them, including expired and revoked ones) and revokes them. Creating +and revoking a waiver needs `report:attest`, requires a reason and an +expiry at most `reports.waiver_max_days` (365) days away, and +is audit-logged (`report_waiver.create`, `report_waiver.revoke`). + +Reports generated while a waiver is in force record it and mark the +results and settings it covers as waived; their status and the score do +not change. Revoking or expiring a waiver never changes reports already +generated. + +## Sign-off + +The **Sign-off** tab records attestations: a person other than the one +who generated the report approves or rejects it, with a comment (required +for a rejection). A report is **Approved** once +`reports.required_approvals` (1 by default) people approved it +and nobody rejected it, and **Rejected** as soon as anyone rejects it. +Signing off needs `report:attest`; each person signs a report off once, +and only reports whose stored bundle still matches its hash can be signed +off. + +Each attestation is a statement that names the report and its bundle's +hash, the decision, the comment, the person and the time, signed by the +server with the report signing key. The signature proves that the server +recorded this decision for the signed-in user; it is not the person's own +signature. Download the attestations and verify them with `bor-verify` +(see above). Every sign-off is audit-logged (`report.attest`). + +## Pinning (legal hold) + +**Pin** keeps a report until it is unpinned: a pinned report cannot be +deleted and the retention purge skips it. Pinning and unpinning need +`report:manage` and are audit-logged (`report.pin`, `report.unpin`). + +## Findings in the SIEM + +When the audit log is forwarded in OCSF format +(`audit.syslog.format: ocsf`), every change of a compliance result is +also forwarded as an OCSF Compliance Finding as the agent reports it: +**Create** for a node's first result of a policy, **Update** when its +status changes and **Close** when it becomes compliant, with the +framework controls of the result and its failing settings. Switch this +off with `reports.ocsf_findings: false`. CEF forwarding does +not carry findings. + +## Monitoring + +The server's Prometheus endpoint exports the number of stored reports, +the time of the newest report per trigger, the fleet compliance score, +schedule states and overdue schedules, the delivery queue and the expiry +of the signing certificate, all named `bor_compliance_*`. See +[Monitoring](../operations/monitoring.md#compliance-reports-bor-enterprise) +for the list and suggested alerts. + +## Retention and deletion + +Reports are kept for `reports.retention_days` (1095 days, three years, +by default); an hourly job deletes older unpinned reports and records a +`report.purge` audit event with the count. The purge keeps running when +the license lapses, so old reports still age out. Set the value to `0` +to keep reports forever. Deleting a report also deletes its +stored PDF and sign-offs. + +Deleting a report needs `report:manage` and is recorded as +`report.delete` with the report's hash; pinned reports must be unpinned +first. There is no undo: download the bundle first when the evidence is +still needed. + +## Personal data + +A report contains node names and FQDNs, policy names and the agent's +messages and per-setting keys, and the user names of the people who +generated it, accepted waivers or signed it off. It never contains IP +addresses or recovery keys. Where setting keys name people (login +mappings, session-access users), generate the report without items or +restrict who holds `report:view`, and let the retention period follow +your records policy. See [Security and compliance](../security/compliance.md). diff --git a/doc/user-guide/web-ui.md b/doc/user-guide/web-ui.md index f3e12d2f..c8aa8bbf 100644 --- a/doc/user-guide/web-ui.md +++ b/doc/user-guide/web-ui.md @@ -94,6 +94,7 @@ hidden. | **Fleet** | **Nodes** | Enrolled machines and their status. See [Nodes](nodes.md). | always | | | **Node Groups** | Groups of nodes and enrollment tokens. See [Node groups](node-groups.md). | always | | | **Compliance** | Per-node, per-policy enforcement results. See [Compliance](compliance.md). | always | +| | **Reports** | Point-in-time compliance reports kept as evidence (Bor Enterprise). See [Compliance reports](reports.md). | `report:view` and an edition with reports | | | **Disk Encryption** | LUKS volumes and escrowed recovery keys. See [Disk encryption](../policy-types/disk-encryption.md). | `disk_encryption:view` | | **Policy** | **Policies** | Create and manage policies. See [Policies](policies.md). | always | | | **Policy Bindings** | Attach policies to node groups. See [Bindings](bindings.md). | always | diff --git a/mkdocs.yml b/mkdocs.yml index 125e8e07..a201ddd8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -98,6 +98,7 @@ nav: - Policies: user-guide/policies.md - Policy bindings: user-guide/bindings.md - Compliance: user-guide/compliance.md + - Compliance reports: user-guide/reports.md - Users, groups and roles: user-guide/users-and-roles.md - Audit log: user-guide/audit-log.md - Settings: user-guide/settings.md diff --git a/server/internal/api/permission_gate.go b/server/internal/api/permission_gate.go index d1892bcb..a044184c 100644 --- a/server/internal/api/permission_gate.go +++ b/server/internal/api/permission_gate.go @@ -126,6 +126,52 @@ func (g *PermissionGate) gate(resolve permissionResolver, scoped bool) func(http } } +// Authenticated admits every signed-in user and loads their grants, so the +// handler decides with Authorize which permission each operation needs. +func (g *PermissionGate) Authenticated() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims := GetUserFromContext(r.Context()) + if claims == nil { + http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized) + return + } + grants, err := g.az.Grants(r.Context(), claims.UserID) + if err != nil { + log.Printf("authorization check failed for user %s: %v", claims.UserID, err) + http.Error(w, `{"error":"authorization check failed"}`, http.StatusInternalServerError) + return + } + state := &authzState{userID: claims.UserID, grants: grants, gate: g} + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), authzContextKey{}, state))) + }) + } +} + +// Authorize returns where the caller of a request that passed a permission +// gate holds resource:action. When the permission is not held, or held only +// for node groups and scoped is false, it answers 403, audits the denial +// and returns false. +func Authorize(w http.ResponseWriter, r *http.Request, resource, action string, scoped bool) (authz.Scope, bool) { + held := requestScope(r, resource, action) + switch { + case held.IsGlobal(), scoped && !held.IsEmpty(): + return held, true + case held.IsEmpty(): + denyRequest(w, r, resource, action, "", "permission not held") + default: + writeJSONError(w, http.StatusForbidden, "this operation requires a global role binding") + auditDenial(r, resource, action, "", "permission held only for node groups; route requires a global grant") + } + return held, false +} + +// DenyOutOfScope answers 403 for an object outside the caller's node-group +// scope and audits it. +func DenyOutOfScope(w http.ResponseWriter, r *http.Request, resource, action, objectID string) { + denyOutOfScope(w, r, resource, action, objectID) +} + // authzState is what a permission gate leaves in the request context. type authzState struct { userID string diff --git a/server/internal/audit/syslog.go b/server/internal/audit/syslog.go index 904a13dc..5f9bda52 100644 --- a/server/internal/audit/syslog.go +++ b/server/internal/audit/syslog.go @@ -48,7 +48,7 @@ type SyslogConfig struct { // events are dropped and a warning is logged. type SyslogSink struct { cfg *SyslogConfig - queue chan *auditpb.AuditEvent + queue chan syslogItem mu sync.Mutex conn net.Conn stopOnce sync.Once @@ -57,12 +57,20 @@ type SyslogSink struct { const syslogQueueSize = 512 +// syslogItem is a queued message: an audit event, or a pre-formatted body +// forwarded for an extension module. +type syslogItem struct { + event *auditpb.AuditEvent + raw string + severity int +} + // NewSyslogSink creates and starts a SyslogSink. // Call Close() to drain the queue and shut down the sender goroutine. func NewSyslogSink(cfg *SyslogConfig) *SyslogSink { s := &SyslogSink{ cfg: cfg, - queue: make(chan *auditpb.AuditEvent, syslogQueueSize), + queue: make(chan syslogItem, syslogQueueSize), stop: make(chan struct{}), } go s.sender() @@ -72,12 +80,28 @@ func NewSyslogSink(cfg *SyslogConfig) *SyslogSink { // Emit enqueues an event for asynchronous delivery. Non-blocking. func (s *SyslogSink) Emit(_ context.Context, event *auditpb.AuditEvent) { select { - case s.queue <- event: + case s.queue <- syslogItem{event: event}: default: log.Printf("audit SyslogSink: queue full, dropping event action=%s", event.GetAction()) } } +// Format is the configured message body format. +func (s *SyslogSink) Format() SyslogFormat { return s.cfg.Format } + +// ForwardRaw enqueues a message body that is already in the configured +// format, with a syslog severity (0-7). Non-blocking. +func (s *SyslogSink) ForwardRaw(body string, severity int) { + if severity < 0 || severity > 7 { + severity = 6 + } + select { + case s.queue <- syslogItem{raw: body, severity: severity}: + default: + log.Printf("audit SyslogSink: queue full, dropping forwarded message") + } +} + // Close stops the sender and waits for the queue to drain (up to 5 s). func (s *SyslogSink) Close() { s.stopOnce.Do(func() { close(s.stop) }) @@ -87,14 +111,14 @@ func (s *SyslogSink) Close() { func (s *SyslogSink) sender() { for { select { - case event := <-s.queue: - s.send(event) + case item := <-s.queue: + s.send(item) case <-s.stop: // Drain remaining events before exit. for { select { - case event := <-s.queue: - s.send(event) + case item := <-s.queue: + s.send(item) default: s.closeConn() return @@ -104,12 +128,17 @@ func (s *SyslogSink) sender() { } } -// send formats and writes one event, reconnecting if necessary. -func (s *SyslogSink) send(event *auditpb.AuditEvent) { - msg, err := s.format(event) - if err != nil { - log.Printf("audit SyslogSink: format error: %v", err) - return +// send formats and writes one message, reconnecting if necessary. +func (s *SyslogSink) send(item syslogItem) { + var msg string + if item.event != nil { + var err error + if msg, err = s.format(item.event); err != nil { + log.Printf("audit SyslogSink: format error: %v", err) + return + } + } else { + msg = s.header(item.severity, time.Now().UTC()) + item.raw } frame := s.frame(msg) @@ -134,24 +163,13 @@ func (s *SyslogSink) send(event *auditpb.AuditEvent) { // format renders the AuditEvent as a RFC 5424 syslog message string. // The syslog header uses priority derived from facility + severity. func (s *SyslogSink) format(event *auditpb.AuditEvent) (string, error) { - facility := s.cfg.Facility - if facility == 0 { - facility = 16 // local0 - } - severity := syslogSeverity(event.GetAction()) - priority := facility*8 + severity ts := time.Now().UTC() if event.GetOccurredAt() != nil { ts = event.GetOccurredAt().AsTime().UTC() } - hostname, _ := os.Hostname() - if hostname == "" { - hostname = "-" - } - var body string switch s.cfg.Format { case FormatOCSFSyslog: @@ -164,13 +182,21 @@ func (s *SyslogSink) format(event *auditpb.AuditEvent) (string, error) { body = FormatCEF(event) } - // RFC 5424: VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG - return fmt.Sprintf("<%d>1 %s %s Bor - - - %s", - priority, - ts.Format(time.RFC3339), - hostname, - body, - ), nil + return s.header(severity, ts) + body, nil +} + +// header renders the RFC 5424 header up to and including the space before +// MSG: VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA. +func (s *SyslogSink) header(severity int, ts time.Time) string { + facility := s.cfg.Facility + if facility == 0 { + facility = 16 // local0 + } + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "-" + } + return fmt.Sprintf("<%d>1 %s %s Bor - - - ", facility*8+severity, ts.Format(time.RFC3339), hostname) } // frame wraps a syslog message for the configured transport. diff --git a/server/internal/authz/authz.go b/server/internal/authz/authz.go index 2c9d0149..7f11724c 100644 --- a/server/internal/authz/authz.go +++ b/server/internal/authz/authz.go @@ -18,6 +18,7 @@ import ( "context" "fmt" "sort" + "sync" "github.com/VuteTech/Bor/server/internal/database" "github.com/VuteTech/Bor/server/internal/models" @@ -43,9 +44,24 @@ var scopableResources = map[string]bool{ "updates": true, } +// scopableMu guards scopableResources against registrations by extension +// modules. +var scopableMu sync.RWMutex + +// RegisterScopable lets node-group-scoped bindings grant permissions on a +// resource whose handlers confine every operation to the caller's groups +// (extension modules register their resources at startup). +func RegisterScopable(resource string) { + scopableMu.Lock() + defer scopableMu.Unlock() + scopableResources[resource] = true +} + // IsScopable reports whether a node-group-scoped binding can grant // permissions on resource. func IsScopable(resource string) bool { + scopableMu.RLock() + defer scopableMu.RUnlock() return scopableResources[resource] } diff --git a/server/internal/database/dconf.go b/server/internal/database/dconf.go index 20baf8d4..95fa4a01 100644 --- a/server/internal/database/dconf.go +++ b/server/internal/database/dconf.go @@ -125,25 +125,32 @@ func (r *DConfRepository) ListSchemasByNode(ctx context.Context, nodeID string) // UpsertComplianceResult inserts or updates a compliance result for a (node, policy) pair. // itemsJSON is a JSON array of per-item results (may be nil/empty for non-dconf policies). -func (r *DConfRepository) UpsertComplianceResult(ctx context.Context, nodeID, policyID, statusStr, message string, itemsJSON []byte) error { +// It returns the status stored before, or "" when this is the pair's first result. +func (r *DConfRepository) UpsertComplianceResult(ctx context.Context, nodeID, policyID, statusStr, message string, itemsJSON []byte) (string, error) { var items any if len(itemsJSON) > 0 { items = itemsJSON } - _, err := r.db.ExecContext(ctx, ` + var previous *string + err := r.db.QueryRowContext(ctx, ` + WITH prev AS (SELECT status FROM compliance_results WHERE node_id = $1 AND policy_id = $2) INSERT INTO compliance_results (node_id, policy_id, status, message, items_json, reported_at) VALUES ($1, $2, $3, $4, $5, NOW()) ON CONFLICT (node_id, policy_id) DO UPDATE SET status = EXCLUDED.status, message = EXCLUDED.message, items_json = EXCLUDED.items_json, - reported_at = EXCLUDED.reported_at`, + reported_at = EXCLUDED.reported_at + RETURNING (SELECT status FROM prev)`, nodeID, policyID, statusStr, nullableString(message), items, - ) + ).Scan(&previous) if err != nil { - return fmt.Errorf("dconf: upsert compliance result: %w", err) + return "", fmt.Errorf("dconf: upsert compliance result: %w", err) } - return nil + if previous == nil { + return "", nil + } + return *previous, nil } // ComplianceRow is a single compliance result row with joined names. diff --git a/server/internal/exthost/exthost.go b/server/internal/exthost/exthost.go index a87becde..e65f7e55 100644 --- a/server/internal/exthost/exthost.go +++ b/server/internal/exthost/exthost.go @@ -8,10 +8,12 @@ package exthost import ( "context" + "crypto" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" + "crypto/x509" "database/sql" "encoding/base64" "encoding/json" @@ -19,6 +21,7 @@ import ( "fmt" "io" "io/fs" + "log" "net/http" "regexp" "sort" @@ -26,12 +29,15 @@ import ( "sync" "github.com/lib/pq" + "github.com/prometheus/client_golang/prometheus" "golang.org/x/crypto/hkdf" "google.golang.org/protobuf/types/known/timestamppb" "github.com/VuteTech/Bor/server/internal/api" + "github.com/VuteTech/Bor/server/internal/authz" "github.com/VuteTech/Bor/server/internal/database" "github.com/VuteTech/Bor/server/internal/models" + "github.com/VuteTech/Bor/server/internal/pki" "github.com/VuteTech/Bor/server/internal/services" "github.com/VuteTech/Bor/server/pkg/ext" auditpb "github.com/VuteTech/Bor/server/pkg/grpc/audit" @@ -56,6 +62,16 @@ type Deps struct { AnonymizeIPs bool // SecretRoot is the server secret module SecretBox keys derive from. SecretRoot string + + // SIEM forwards module messages with the audit log; nil when audit + // forwarding is off. + SIEM ext.SIEM + // Agents acts on connected agents; nil in tests. + Agents ext.Agents + // CACert and CAKey are the server's certificate authority, for + // certificates modules ask for through Host.PKI(). + CACert *x509.Certificate + CAKey crypto.Signer } var moduleName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) @@ -163,6 +179,107 @@ func (h *host) Identity() ext.Identity { return &identity{deps: h.deps, modul func (h *host) Logins() ext.LoginRegistry { return &logins{reg: h.reg, prefix: h.prefix()} } func (h *host) Audit() ext.Auditor { return &auditor{deps: h.deps} } +func (h *host) HandleUser(pattern string, handler http.Handler) error { + return h.handle(pattern, h.deps.AuthMiddleware(h.deps.Gate.Authenticated()(handler))) +} + +func (h *host) Authorize(w http.ResponseWriter, r *http.Request, resource, action string, scoped bool) (ext.Scope, bool) { + held, ok := api.Authorize(w, r, resource, action, scoped) + return ext.Scope{Global: held.IsGlobal(), NodeGroupIDs: held.GroupIDs()}, ok +} + +func (h *host) DenyOutOfScope(w http.ResponseWriter, r *http.Request, resource, action, objectID string) { + api.DenyOutOfScope(w, r, resource, action, objectID) +} + +func (h *host) RegisterScopableResource(resource string) error { + if !moduleName.MatchString(strings.ReplaceAll(resource, "_", "-")) { + return fmt.Errorf("invalid resource name %q", resource) + } + authz.RegisterScopable(resource) + return nil +} + +func (h *host) Agents() ext.Agents { + if h.deps.Agents == nil { + return noAgents{} + } + return h.deps.Agents +} + +// noAgents is the agent control of a server without a policy hub (tests). +type noAgents struct{} + +func (noAgents) Resync(string) bool { return false } + +func (h *host) RegisterComplianceListener(l ext.ComplianceListener) error { + if l == nil { + return errors.New("nil compliance listener") + } + h.reg.mu.Lock() + defer h.reg.mu.Unlock() + h.reg.listeners = append(h.reg.listeners, l) + return nil +} + +func (h *host) RegisterMetrics(c prometheus.Collector) error { + if c == nil { + return errors.New("nil metrics collector") + } + h.reg.mu.Lock() + defer h.reg.mu.Unlock() + h.reg.collectors = append(h.reg.collectors, c) + return nil +} + +func (h *host) SIEM() ext.SIEM { + if h.deps.SIEM == nil { + return noSIEM{} + } + return h.deps.SIEM +} + +// noSIEM is the SIEM of a server that does not forward its audit log. +type noSIEM struct{} + +func (noSIEM) Format() string { return "" } +func (noSIEM) Forward(string, int) {} + +func (h *host) PKI() ext.PKI { return &pkiHost{deps: h.deps, module: h.name} } + +// pkiHost issues certificates from the server CA for one module. +type pkiHost struct { + deps *Deps + module string +} + +func (p *pkiHost) CACertificatePEM() []byte { + if p.deps.CACert == nil { + return nil + } + return pki.EncodeCertPEM(p.deps.CACert) +} + +func (p *pkiHost) IssueCertificate(_ context.Context, req ext.CertificateRequest) ([]byte, error) { + if p.deps.CACert == nil || p.deps.CAKey == nil { + return nil, errors.New("ext: the certificate authority is not available") + } + if req.PublicKey == nil { + return nil, errors.New("ext: a public key is required") + } + switch req.Usage { + case ext.CertificateUsageDocumentSigning: + certPEM, err := pki.IssueDocumentSigningCert(req.PublicKey, p.deps.CACert, p.deps.CAKey, req.CommonName, req.Validity) + if err != nil { + return nil, err + } + log.Printf("ext: module %q was issued a document signing certificate for %q", p.module, req.CommonName) + return certPEM, nil + default: + return nil, fmt.Errorf("ext: unsupported certificate usage %q", req.Usage) + } +} + func (h *host) Secrets() ext.SecretBox { r := hkdf.New(sha256.New, []byte(h.deps.SecretRoot), []byte("bor-ext-secretbox-v1"), []byte("ext:"+h.name)) key := make([]byte, 32) @@ -538,6 +655,35 @@ type Registry struct { stepUps map[string]func(username, purpose string) string logouts map[string]string panels []ext.SettingsPanel + // listeners are told about every reported compliance result. + listeners []ext.ComplianceListener + // collectors are added to the Prometheus metrics endpoint. + collectors []prometheus.Collector +} + +// Collectors returns the metrics collectors the modules registered. +func (reg *Registry) Collectors() []prometheus.Collector { + reg.mu.RLock() + defer reg.mu.RUnlock() + return append([]prometheus.Collector(nil), reg.collectors...) +} + +// HasComplianceListeners reports whether a module listens to compliance +// results, so the caller can skip preparing a change nobody receives. +func (reg *Registry) HasComplianceListeners() bool { + reg.mu.RLock() + defer reg.mu.RUnlock() + return len(reg.listeners) > 0 +} + +// ComplianceReported tells every listening module about c. +func (reg *Registry) ComplianceReported(ctx context.Context, c *ext.ComplianceChange) { + reg.mu.RLock() + listeners := append([]ext.ComplianceListener(nil), reg.listeners...) + reg.mu.RUnlock() + for _, l := range listeners { + l.ComplianceReported(ctx, c) + } } // NewRegistry creates an empty Registry. diff --git a/server/internal/exthost/exthost_test.go b/server/internal/exthost/exthost_test.go index 9e69dc33..4b84b97b 100644 --- a/server/internal/exthost/exthost_test.go +++ b/server/internal/exthost/exthost_test.go @@ -11,6 +11,8 @@ import ( "reflect" "testing" + "github.com/prometheus/client_golang/prometheus" + "github.com/VuteTech/Bor/server/pkg/ext" ) @@ -202,3 +204,18 @@ func TestDecideMFA(t *testing.T) { } } } + +func TestHost_RegisterMetrics(t *testing.T) { + reg := NewRegistry() + h := &host{name: "reports", deps: testDeps(), reg: reg} + if err := h.RegisterMetrics(nil); err == nil { + t.Error("a nil collector must be rejected") + } + g := prometheus.NewGauge(prometheus.GaugeOpts{Name: "bor_test", Help: "Test."}) + if err := h.RegisterMetrics(g); err != nil { + t.Fatal(err) + } + if got := reg.Collectors(); len(got) != 1 || got[0] != g { + t.Errorf("Collectors() = %v", got) + } +} diff --git a/server/internal/grpc/server.go b/server/internal/grpc/server.go index 4b9bdcfa..4188cb2c 100644 --- a/server/internal/grpc/server.go +++ b/server/internal/grpc/server.go @@ -11,9 +11,11 @@ import ( "regexp" "slices" "strings" + "time" "github.com/VuteTech/Bor/server/internal/models" "github.com/VuteTech/Bor/server/internal/services" + "github.com/VuteTech/Bor/server/pkg/ext" auditpb "github.com/VuteTech/Bor/server/pkg/grpc/audit" pb "github.com/VuteTech/Bor/server/pkg/grpc/policy" "google.golang.org/grpc/codes" @@ -47,13 +49,30 @@ type PolicyServer struct { updatesActions updatesActionSink hub *PolicyHub diskEncSvc *services.DiskEncryptionService + // compliance is told about every reported compliance result; nil when + // nobody listens. + compliance complianceListener +} + +// complianceListener receives reported compliance results +// (reports.Registry implements it for extension modules). +type complianceListener interface { + HasComplianceListeners() bool + ComplianceReported(ctx context.Context, c *ext.ComplianceChange) +} + +// WithComplianceListener tells l about every compliance result an agent +// reports. +func (s *PolicyServer) WithComplianceListener(l complianceListener) *PolicyServer { + s.compliance = l + return s } // dconfRepository is the subset of database.DConfRepository used by PolicyServer. type dconfRepository interface { UpsertSchema(ctx context.Context, schema *pb.GSettingsSchema, source string) error ReplaceNodeSchemas(ctx context.Context, nodeID string, schemaIDs []string) error - UpsertComplianceResult(ctx context.Context, nodeID, policyID, statusStr, message string, itemsJSON []byte) error + UpsertComplianceResult(ctx context.Context, nodeID, policyID, statusStr, message string, itemsJSON []byte) (previous string, err error) } // polkitRepository is the subset of database.PolkitRepository used by PolicyServer. @@ -342,19 +361,43 @@ func (s *PolicyServer) ReportCompliance(ctx context.Context, req *pb.ReportCompl Message: it.GetMessage(), }) } - if b, err := json.Marshal(arr); err == nil { + if b, mErr := json.Marshal(arr); mErr == nil { itemsJSON = b } } - if err := s.dconfRepo.UpsertComplianceResult(ctx, node.ID, req.GetPolicyId(), statusStr, req.GetMessage(), itemsJSON); err != nil { + previous, err := s.dconfRepo.UpsertComplianceResult(ctx, node.ID, req.GetPolicyId(), statusStr, req.GetMessage(), itemsJSON) + if err != nil { log.Printf("WARNING: ReportCompliance: failed to persist result for node %s policy %s: %v", node.ID, req.GetPolicyId(), err) return nil, status.Errorf(codes.Internal, "failed to persist compliance result") } + if s.compliance != nil && s.compliance.HasComplianceListeners() { + change := &ext.ComplianceChange{ + NodeID: node.ID, NodeName: node.Name, OSName: derefString(node.OSName), OSVersion: derefString(node.OSVersion), + PolicyID: req.GetPolicyId(), PreviousStatus: previous, Status: statusStr, Message: req.GetMessage(), + ReportedAt: time.Now().UTC(), + } + if p, perr := s.policySvc.GetPolicy(ctx, req.GetPolicyId()); perr == nil && p != nil { + change.PolicyName, change.PolicyType = p.Name, p.Type + } + for _, it := range req.GetItems() { + change.Items = append(change.Items, ext.ComplianceItem{ + SchemaID: it.GetSchemaId(), Key: it.GetKey(), Status: complianceStatusToString(it.GetStatus(), false), Message: it.GetMessage(), + }) + } + s.compliance.ComplianceReported(ctx, change) + } return &pb.ReportComplianceResponse{Success: true}, nil } +func derefString(s *string) string { + if s == nil { + return "" + } + return *s +} + // complianceStatusToString converts a pb.ComplianceStatus to its VARCHAR representation. // Falls back to the legacy compliant bool when status is UNKNOWN (old agents). func complianceStatusToString(s pb.ComplianceStatus, legacyCompliant bool) string { diff --git a/server/internal/metrics/server.go b/server/internal/metrics/server.go index 11ddd6a0..6374d482 100644 --- a/server/internal/metrics/server.go +++ b/server/internal/metrics/server.go @@ -5,6 +5,7 @@ package metrics import ( + "log" "net/http" "strings" "time" @@ -19,14 +20,22 @@ import ( // addr is the listen address in host:port form (e.g. "127.0.0.1:9090"). // When bearerToken is non-empty every request must carry // "Authorization: Bearer "; otherwise no authentication is applied. -// Every collector in cs is registered on the server's registry. +// Every collector in cs is registered on the server's registry. The +// collectors of extension modules in ext are registered after them; one the +// registry rejects (an invalid or clashing metric) is logged and left out +// rather than stopping the server. // // The returned *http.Server is not started — call ListenAndServe in a goroutine. -func NewServer(addr, bearerToken string, cs ...prometheus.Collector) *http.Server { +func NewServer(addr, bearerToken string, ext []prometheus.Collector, cs ...prometheus.Collector) *http.Server { reg := prometheus.NewRegistry() for _, c := range cs { reg.MustRegister(c) } + for _, c := range ext { + if err := reg.Register(c); err != nil { + log.Printf("metrics: extension collector not registered: %v", err) + } + } reg.MustRegister(collectors.NewGoCollector()) reg.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) diff --git a/server/internal/metrics/server_test.go b/server/internal/metrics/server_test.go new file mode 100644 index 00000000..2509ffe2 --- /dev/null +++ b/server/internal/metrics/server_test.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vute Tech LTD +// Copyright (C) 2026 Bor contributors + +package metrics + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus" +) + +func TestNewServer_ExtensionCollectors(t *testing.T) { + core := prometheus.NewGauge(prometheus.GaugeOpts{Name: "bor_core_test", Help: "Core."}) + module := prometheus.NewGauge(prometheus.GaugeOpts{Name: "bor_module_test", Help: "Module."}) + module.Set(7) + // A module collector that clashes with a core metric is left out; it + // must not stop the server. + clash := prometheus.NewGauge(prometheus.GaugeOpts{Name: "bor_core_test", Help: "Clash."}) + + srv := NewServer("127.0.0.1:0", "", []prometheus.Collector{module, clash}, core) + rec := httptest.NewRecorder() + srv.Handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", http.NoBody)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + body, _ := io.ReadAll(rec.Body) + for _, want := range []string{"bor_core_test 0", "bor_module_test 7"} { + if !strings.Contains(string(body), want) { + t.Errorf("metrics output lacks %q", want) + } + } + if strings.Contains(string(body), "Clash.") { + t.Error("the clashing collector was registered") + } +} diff --git a/server/internal/pki/pki.go b/server/internal/pki/pki.go index 42ad67f9..45b83fcf 100644 --- a/server/internal/pki/pki.go +++ b/server/internal/pki/pki.go @@ -15,6 +15,7 @@ import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "encoding/asn1" "encoding/pem" "fmt" "math/big" @@ -466,3 +467,45 @@ func fileExists(path string) bool { _, err := os.Stat(path) return err == nil } + +// oidDocumentSigning is id-kp-documentSigning (RFC 9336), the extended key +// usage of certificates that sign documents such as compliance reports. +var oidDocumentSigning = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 36} + +// DefaultDocumentSigningValidity is the validity of a document-signing +// certificate when the caller does not choose one. +const DefaultDocumentSigningValidity = 365 * 24 * time.Hour + +// IssueDocumentSigningCert issues a certificate for pub from the CA with the +// document-signing extended key usage and no TLS usages, so it can never +// authenticate as a server or an agent. The public key must meet the same +// strength baseline as enrollment keys. +func IssueDocumentSigningCert(pub crypto.PublicKey, caCert *x509.Certificate, caKey crypto.Signer, commonName string, validity time.Duration) ([]byte, error) { + if commonName == "" { + return nil, fmt.Errorf("document signing certificate: common name is required") + } + if err := validateCSRPublicKey(pub); err != nil { + return nil, err + } + if validity <= 0 { + validity = DefaultDocumentSigningValidity + } + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, fmt.Errorf("failed to generate serial number: %w", err) + } + tmpl := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{CommonName: commonName}, + NotBefore: time.Now().Add(-1 * time.Minute), + NotAfter: time.Now().Add(validity), + KeyUsage: x509.KeyUsageDigitalSignature, + UnknownExtKeyUsage: []asn1.ObjectIdentifier{oidDocumentSigning}, + BasicConstraintsValid: true, + } + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, caCert, pub, caKey) + if err != nil { + return nil, fmt.Errorf("failed to sign document signing certificate: %w", err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}), nil +} diff --git a/server/pkg/app/app.go b/server/pkg/app/app.go index 60b667cd..cf9710fd 100644 --- a/server/pkg/app/app.go +++ b/server/pkg/app/app.go @@ -496,6 +496,7 @@ func Run(opts Options) { WithTangService(tangSvc). WithMetrics(luksEscrowOps, luksReveals) diskEncSvc.StartJanitor(context.Background()) + stepUpSvc := services.NewStepUpService(authSvc, mfaSvc, cfg.Security.JWTSecret, cfg.DiskEncryption.RequireMFAForReveal) diskEncHandler := api.NewDiskEncryptionHandler(diskEncSvc, stepUpSvc, cfg.Audit.AnonymizeIPs) tangServersHandler := api.NewTangServersHandler(tangSvc) @@ -784,6 +785,10 @@ func Run(opts Options) { // Extension modules mount their routes under /api/v1/ext//. mux.Handle("/api/v1/sso-link-approvals/", authMiddleware(gate.Require("user", "edit")(auditMw(api.NewSSOLinkApprovalHandler(authSvc, userRepo))))) mux.Handle("/api/v1/ext-panels", authMiddleware(gate.Require("settings", "manage")(http.HandlerFunc(extLogins.Panels)))) + var siem ext.SIEM + if syslogSink != nil { + siem = syslogSIEM{syslogSink} + } if extErr := exthost.Init(context.Background(), &exthost.Deps{ Mux: mux, AuthMiddleware: authMiddleware, @@ -799,6 +804,10 @@ func Run(opts Options) { AuditSvc: auditSvc, AnonymizeIPs: cfg.Audit.AnonymizeIPs, SecretRoot: cfg.Security.JWTSecret, + Agents: hubAgents{policyHub}, + SIEM: siem, + CACert: caCert, + CAKey: caKey, }, extLogins, opts.Extensions); extErr != nil { log.Fatalf("Failed to initialize extensions: %v", extErr) } @@ -929,7 +938,8 @@ func Run(opts Options) { WithMacRepository(macRepo). WithUpdatesRepository(updatesRepo). WithMacActions(macActions). - WithUpdatesActions(updatesActions)) + WithUpdatesActions(updatesActions). + WithComplianceListener(extLogins)) // ─── UI + Enrollment server (:8443) — VerifyClientCertIfGiven ──────── // Explicit cipher suites per BSI TR-02102-2 (2024): ECDHE+AEAD only. @@ -1004,7 +1014,7 @@ func Run(opts Options) { nodeRepo, policyRepo, policyBindingRepo, auditLogRepo, userRepo, dconfRepo, flatpakRepo, luksRepo, ) - metricsServer := metrics.NewServer(cfg.Metrics.ListenAddr, cfg.Metrics.BearerToken, metricsCollector, agentPackageDownloads, flatpakRefreshes, luksEscrowOps, luksReveals) + metricsServer := metrics.NewServer(cfg.Metrics.ListenAddr, cfg.Metrics.BearerToken, extLogins.Collectors(), metricsCollector, agentPackageDownloads, flatpakRefreshes, luksEscrowOps, luksReveals) // Start both servers. go func() { @@ -1064,6 +1074,18 @@ func Run(opts Options) { log.Println("Server stopped") } +// hubAgents lets extension modules ask connected agents to re-sync. +type hubAgents struct{ hub *grpcserver.PolicyHub } + +func (a hubAgents) Resync(nodeName string) bool { return a.hub.SendResync(nodeName) } + +// syslogSIEM forwards extension module messages through the audit syslog +// sink. +type syslogSIEM struct{ sink *auditsink.SyslogSink } + +func (s syslogSIEM) Format() string { return string(s.sink.Format()) } +func (s syslogSIEM) Forward(body string, severity int) { s.sink.ForwardRaw(body, severity) } + // ResetMFA connects to the database using environment config, looks up the // user by username, and deletes their MFA record (the -reset-mfa recovery // path for a locked-out administrator). diff --git a/server/pkg/edition/edition.go b/server/pkg/edition/edition.go index cd7a0d80..c6d98e5a 100644 --- a/server/pkg/edition/edition.go +++ b/server/pkg/edition/edition.go @@ -18,10 +18,14 @@ const ( // bindings can be created and existing node-group-scoped bindings grant // nothing. NodeGroupScopedRBAC Feature = "node_group_scoped_rbac" + // ComplianceReports is the license feature of compliance reports. The + // reports are implemented by a server extension (Bor Enterprise); the + // web UI shows the Reports page only while the edition enables it. + ComplianceReports Feature = "reports" ) // AllFeatures lists every known Feature, in a stable order. -var AllFeatures = []Feature{NodeGroupScopedRBAC} +var AllFeatures = []Feature{NodeGroupScopedRBAC, ComplianceReports} // Edition reports the running edition and its enabled features. type Edition interface { diff --git a/server/pkg/ext/ext.go b/server/pkg/ext/ext.go index 75ae076e..5cc809c0 100644 --- a/server/pkg/ext/ext.go +++ b/server/pkg/ext/ext.go @@ -14,11 +14,15 @@ package ext import ( "context" + "crypto" "database/sql" "errors" "io/fs" "net/http" "net/url" + "time" + + "github.com/prometheus/client_golang/prometheus" ) // Module is an optional server component. @@ -41,6 +45,22 @@ type Host interface { // holding resource:action globally. Same prefix rule as HandlePublic. // State-changing requests are recorded by the audit middleware. HandleAdmin(pattern, resource, action string, h http.Handler) error + // HandleUser mounts a handler for any signed-in user (same prefix rule). + // The handler checks each operation's permission with Authorize and + // audits its own changes. + HandleUser(pattern string, h http.Handler) error + // Authorize returns where the user of a HandleUser request holds + // resource:action. When it is not held, or held only for node groups + // and scoped is false, Authorize answers 403, audits the denial and + // returns false. + Authorize(w http.ResponseWriter, r *http.Request, resource, action string, scoped bool) (Scope, bool) + // DenyOutOfScope answers 403 for an object outside the caller's + // node-group scope and audits it. + DenyOutOfScope(w http.ResponseWriter, r *http.Request, resource, action, objectID string) + // RegisterScopableResource lets node-group-scoped role bindings grant + // the module's permissions on resource; its handlers confine every + // operation to the caller's groups. + RegisterScopableResource(resource string) error // RunMigrations applies the module's *.up.sql files in dir of fsys, in // name order, once each. They are tracked per module, so their numbering @@ -62,6 +82,52 @@ type Host interface { // Secrets encrypts and decrypts data at rest with a key derived for this // module from the server's secret. Secrets() SecretBox + // PKI issues certificates from the server's certificate authority. + PKI() PKI + // SIEM forwards messages to the system the audit log is forwarded to. + SIEM() SIEM + // Agents acts on connected agents. + Agents() Agents + // RegisterComplianceListener adds a listener that is told about every + // compliance result an agent reports. + RegisterComplianceListener(l ComplianceListener) error + // RegisterMetrics adds a collector to the server's Prometheus metrics + // endpoint. Call it from Init. Metric names start with bor_ and must not + // clash with the server's own; a collector the registry rejects is + // logged and left out. + RegisterMetrics(c prometheus.Collector) error +} + +// Scope is where a user holds a permission: everywhere, or in some node +// groups. +type Scope struct { + Global bool + NodeGroupIDs []string +} + +// AllowsAll reports whether every group in ids lies inside the scope. +func (s Scope) AllowsAll(ids []string) bool { + if s.Global { + return true + } + held := make(map[string]bool, len(s.NodeGroupIDs)) + for _, id := range s.NodeGroupIDs { + held[id] = true + } + for _, id := range ids { + if !held[id] { + return false + } + } + return true +} + +// Agents acts on the agents connected to the server. +type Agents interface { + // Resync asks the connected agent of a node (by node name) to re-apply + // its policies and report compliance again. It returns false when the + // agent is not connected. + Resync(nodeName string) bool } // User is a Bor user as seen by modules. @@ -402,3 +468,78 @@ type PanelAction struct { // "" (nothing). Result string `json:"result,omitempty"` } + +// ─── Compliance results, certificates, SIEM ───────────────────────────── + +// ComplianceItem is one setting of a compliance result. +type ComplianceItem struct { + SchemaID string + Key string + Status string + Message string +} + +// ComplianceChange is a compliance result an agent has just reported. +type ComplianceChange struct { + NodeID string + NodeName string + OSName string + OSVersion string + PolicyID string + // PolicyName and PolicyType are empty when the policy is unknown. + PolicyName string + PolicyType string + // PreviousStatus is the status stored before this report, or "" for + // the first result of the node and policy. + PreviousStatus string + Status string + Message string + Items []ComplianceItem + ReportedAt time.Time +} + +// ComplianceListener is told about compliance results as agents report +// them. ComplianceReported runs on the agent's request: it must return +// quickly and must not fail the report. +type ComplianceListener interface { + ComplianceReported(ctx context.Context, c *ComplianceChange) +} + +// SIEM forwards messages to the security information and event management +// system the server's audit log goes to (syslog forwarding). +type SIEM interface { + // Format is the configured message format, "ocsf" or "cef", or "" + // when forwarding is off. + Format() string + // Forward queues one message body in Format with a syslog severity + // (0 emergency to 7 debug). It never blocks. + Forward(body string, severity int) +} + +// CertificateRequest asks the server's certificate authority for a +// certificate. +type CertificateRequest struct { + // CommonName is the subject's common name. + CommonName string + // PublicKey is the key to certify: ECDSA P-256 or stronger, RSA-3072 or + // stronger, or Ed25519. + PublicKey crypto.PublicKey + // Usage selects the certificate profile. "document-signing" (extended + // key usage id-kp-documentSigning) is the only profile today. + Usage string + // Validity is how long the certificate is valid; zero means one year. + Validity time.Duration +} + +// Certificate usage profiles for CertificateRequest.Usage. +const ( + CertificateUsageDocumentSigning = "document-signing" +) + +// PKI is the server's certificate authority as seen by modules. +type PKI interface { + // CACertificatePEM returns the CA certificate the agents trust. + CACertificatePEM() []byte + // IssueCertificate signs a certificate for req and returns it in PEM. + IssueCertificate(ctx context.Context, req CertificateRequest) (certPEM []byte, err error) +} diff --git a/server/web/frontend/src/Shell.tsx b/server/web/frontend/src/Shell.tsx index 523a5d84..8b2b3b91 100644 --- a/server/web/frontend/src/Shell.tsx +++ b/server/web/frontend/src/Shell.tsx @@ -41,7 +41,7 @@ import AdjustIcon from "@patternfly/react-icons/dist/esm/icons/adjust-icon"; import { checkSession, logout, getMFAStatus, getPublicConfig, UserInfo } from "./apiClient/authApi"; import { getServerVersion } from "./apiClient/systemApi"; -import { setPermissions, clearPermissions, hasPermission } from "./apiClient/permissions"; +import { setPermissions, clearPermissions, hasPermission, hasFeature, FEATURE_COMPLIANCE_REPORTS } from "./apiClient/permissions"; import { onSessionExpired } from "./apiClient/session"; import { PageHeader } from "./components/PageHeader"; import { DeployAgentProvider, DeployAgentMastheadButton } from "./components/DeployAgentModal"; @@ -56,10 +56,11 @@ import { PolicyBindingsPage } from "./views/PolicyBindings"; import { SettingsPage } from "./views/Settings"; import { AuditLogsPage } from "./views/AuditLogs"; import { CompliancePage } from "./views/Compliance"; +import { ReportsPage, ReportDetailPage, EditionNotice } from "./views/Reports"; import { DiskEncryptionPage } from "./views/DiskEncryption"; import logoWhite from "./assets/logo-white.svg"; -type ScreenKey = "dashboard" | "policies" | "nodes" | "node-groups" | "policy-bindings" | "compliance" | "disk-encryption" | "audit-logs" | "settings"; +type ScreenKey = "dashboard" | "policies" | "nodes" | "node-groups" | "policy-bindings" | "compliance" | "reports" | "disk-encryption" | "audit-logs" | "settings"; type ThemeMode = "light" | "dark" | "system"; const PAGE_NAMES: Record = { @@ -69,6 +70,7 @@ const PAGE_NAMES: Record = { "node-groups": "Node Groups", "policy-bindings": "Policy Bindings", compliance: "Compliance", + reports: "Reports", "disk-encryption": "Disk Encryption", "audit-logs": "Audit Logs", settings: "Settings", @@ -81,6 +83,7 @@ const PAGE_SUBTITLES: Record = { "node-groups": "Manage node groups and generate enrollment tokens for agent registration.", "policy-bindings": "Bind policies to node groups. Nodes inherit policies through group membership.", compliance: "Track policy enforcement status across your fleet.", + reports: "Point-in-time compliance reports: immutable evidence of what the fleet looked like when they were generated.", "disk-encryption": "The fleet's LUKS volumes and escrowed recovery keys - reveal or rotate a key when a device needs recovery.", "audit-logs": "Track system changes and security events.", settings: "Manage users, roles, and system configuration.", @@ -94,6 +97,7 @@ const SCREEN_PATH: Record = { "node-groups": "/node-groups", "policy-bindings": "/policy-bindings", compliance: "/compliance", + reports: "/reports", "disk-encryption": "/disk-encryption", "audit-logs": "/audit-logs", settings: "/settings", @@ -447,6 +451,7 @@ export const Shell: React.FC = () => { {navItem("nodes", "Nodes")} {navItem("node-groups", "Node Groups")} {navItem("compliance", "Compliance")} + {hasFeature(FEATURE_COMPLIANCE_REPORTS) && hasPermission("report:view") && navItem("reports", "Reports")} {hasPermission("disk_encryption:view") && navItem("disk-encryption", "Disk Encryption")} @@ -550,6 +555,8 @@ export const Shell: React.FC = () => { } /> } /> } /> + : } /> + : } /> } /> } /> } /> diff --git a/server/web/frontend/src/apiClient/permissions.ts b/server/web/frontend/src/apiClient/permissions.ts index de2e93ef..fa8c8fd3 100644 --- a/server/web/frontend/src/apiClient/permissions.ts +++ b/server/web/frontend/src/apiClient/permissions.ts @@ -17,6 +17,8 @@ export interface PermissionScope { /** Optional server features, as reported by /api/v1/auth/me. */ export const FEATURE_NODE_GROUP_SCOPED_RBAC = "node_group_scoped_rbac"; +/** Point-in-time compliance reports (the Reports page and its API). */ +export const FEATURE_COMPLIANCE_REPORTS = "reports"; let currentPermissions: Set = new Set(); let currentScopes: Record = {}; diff --git a/server/web/frontend/src/apiClient/reportsApi.ts b/server/web/frontend/src/apiClient/reportsApi.ts new file mode 100644 index 00000000..2692aa2d --- /dev/null +++ b/server/web/frontend/src/apiClient/reportsApi.ts @@ -0,0 +1,586 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vute Tech LTD +// Copyright (C) 2026 Bor contributors + +import { authHeaders } from "./authApi"; + +async function apiRequest(url: string, init?: RequestInit): Promise { + const res = await fetch(url, { credentials: "same-origin", ...init }); + if (!res.ok) { + let detail = res.statusText; + try { + const b = await res.json(); + if (b.error) detail = b.error; + } catch { + /* swallow */ + } + throw new Error(detail); + } + if (res.status === 204) return undefined as unknown as T; + return res.json(); +} + +/* ── Types ── */ + +export type ReportTrigger = "manual" | "scheduled" | "daily_summary" | "api"; + +export interface ReportRef { + id: string; + name: string; +} + +export interface ReportScope { + all_nodes: boolean; + node_groups: ReportRef[]; + policy_types: string[]; + policies: ReportRef[]; +} + +export interface ReportStatusCounts { + compliant: number; + non_compliant: number; + error: number; + inapplicable: number; + unknown: number; +} + +/** Result of checking a stored report when it is opened. */ +export interface ReportIntegrity { + sha256: string; + /** ok | mismatch | missing */ + hash: string; + /** unsigned | valid | invalid | unverified */ + signature: string; + signature_format?: string; + signature_key_id?: string; +} + +export interface ReportSummary { + id: string; + generated_at: string; + trigger: ReportTrigger | string; + kind: string; + generated_by: string; + generated_by_user_id?: string; + label: string; + notes: string; + scope: ReportScope; + nodes: number; + nodes_online: number; + nodes_unreachable: number; + results: number; + results_stale: number; + /** Failed results whose failures are all covered by waivers. */ + results_waived: number; + by_status: ReportStatusCounts; + score_pct: number | null; + schedule_id?: string; + pinned: boolean; + pinned_by?: string; + pinned_at?: string; + bundle_bytes: number; + sha256?: string; + signed: boolean; + /** Set when the report was opened, not in lists. */ + integrity?: ReportIntegrity; + /** Download formats available for this report (json, csv, jws, plus renderer formats). */ + formats?: string[]; +} + +export interface PagedResponse { + items: T[]; + total: number; + page: number; + per_page: number; + total_pages: number; +} + +export interface ReportListParams { + page?: number; + per_page?: number; + q?: string; + trigger?: string; + group?: string; + /** full (default), summary or all */ + kind?: string; +} + +/** What this server offers for reports (extension modules add capabilities). */ +export interface ReportFramework { + name: string; + short: string; + version: string; + url?: string; +} + +export interface ReportCapabilities { + /** e.g. "schedules", "waivers", "attestations", "frameworks" */ + capabilities: string[]; + formats: string[]; + frameworks: ReportFramework[]; + frameworks_version: string; + /** Fresh-evidence collection is available. */ + refresh: boolean; + daily_summary: boolean; + stale_after_seconds: number; +} + +export interface CreateReportRequest { + scope: { + all_nodes: boolean; + node_group_ids: string[]; + policy_types: string[]; + }; + label: string; + notes: string; + include_items: boolean; + /** Ask the online agents in scope to re-check before the snapshot. */ + refresh?: boolean; +} + +export interface ReportNodeRow { + node_id: string; + node_name: string; + os_name?: string; + os_version?: string; + agent_version?: string; + status: string; + last_seen?: string; + unreachable: boolean; + groups: string[]; + lsm?: string; + lsm_mode?: string; + encrypted?: boolean; + escrowed?: boolean; + pending_security?: number; + reboot_required?: boolean; + by_status: ReportStatusCounts; +} + +export interface ReportNodeParams { + page?: number; + per_page?: number; + status?: string; + unreachable?: boolean; + q?: string; +} + +export interface ReportItemControl { + framework: string; + id: string; +} + +export interface ReportItem { + schema_id: string; + key: string; + status: string; + message?: string; + controls?: ReportItemControl[]; + /** Id of the waiver that covers the item's failure. */ + waiver?: string; +} + +export interface ReportResultRow { + node_id: string; + node_name: string; + policy_id: string; + policy_name: string; + policy_type: string; + policy_version: number; + status: string; + message?: string; + reported_at?: string; + stale: boolean; + /** Every failure of the result is covered by a waiver. */ + waived: boolean; + /** Number of per-setting items the result carried. */ + items: number; + items_failed: number; + /** The items, when the page was requested with include_items. */ + item_list?: ReportItem[]; +} + +export interface ReportResultParams { + page?: number; + per_page?: number; + status?: string; + policy?: string; + type?: string; + node?: string; + stale?: boolean; + waived?: boolean; + q?: string; + include_items?: boolean; +} + +export interface ReportPolicyRow { + policy_id: string; + policy_name: string; + policy_type: string; + policy_version: number; + nodes: number; + by_status: ReportStatusCounts; +} + +/* ── API calls ── */ + +type QueryValue = string | number | boolean | undefined; + +function buildQuery(params: object): string { + const qs = new URLSearchParams(); + for (const [key, value] of Object.entries(params as Record)) { + if (value === undefined || value === null || value === "") continue; + qs.set(key, typeof value === "boolean" ? (value ? "1" : "0") : String(value)); + } + const s = qs.toString(); + return s ? `?${s}` : ""; +} + +export async function listReports(params: ReportListParams = {}): Promise> { + return apiRequest>(`/api/v1/ext/reports/reports${buildQuery(params)}`, { + headers: authHeaders(), + }); +} + +export async function getReport(id: string): Promise { + return apiRequest(`/api/v1/ext/reports/reports/${encodeURIComponent(id)}`, { + headers: authHeaders(), + }); +} + +export async function createReport(body: CreateReportRequest): Promise { + return apiRequest("/api/v1/ext/reports/reports", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(body), + }); +} + +export async function fetchReportNodes(id: string, params: ReportNodeParams = {}): Promise> { + const { unreachable, ...rest } = params; + const query: Record = { ...rest }; + if (unreachable !== undefined) query.unreachable = unreachable; + return apiRequest>( + `/api/v1/ext/reports/reports/${encodeURIComponent(id)}/nodes${buildQuery(query)}`, + { headers: authHeaders() }, + ); +} + +export async function fetchReportResults(id: string, params: ReportResultParams = {}): Promise> { + const { stale, include_items, ...rest } = params; + const query: Record = { ...rest }; + if (stale !== undefined) query.stale = stale; + if (include_items) query.include_items = true; + return apiRequest>( + `/api/v1/ext/reports/reports/${encodeURIComponent(id)}/results${buildQuery(query)}`, + { headers: authHeaders() }, + ); +} + +export interface ReportControlRow { + framework: string; + id: string; + title: string; + pass: number; + fail: number; + error: number; + inapplicable: number; + nodes_failing: number; +} + +export interface ControlFinding { + node_id: string; + node: string; + policy_id: string; + policy_name: string; + policy_type: string; + schema_id?: string; + key?: string; + status: string; + message?: string; + stale: boolean; + waiver?: string; +} + +export interface ReportChange { + node_id: string; + node_name: string; + policy_id: string; + policy_name: string; + policy_type: string; + before: string; + after: string; +} + +export interface ReportComparison { + base: ReportSummary; + target: ReportSummary; + new_failures: ReportChange[]; + new_failures_total: number; + fixed: ReportChange[]; + fixed_total: number; + still_failing: number; + nodes_added: { node_id: string; node_name: string }[]; + nodes_removed: { node_id: string; node_name: string }[]; + policies_added: { policy_id: string; policy_name: string; policy_type: string }[]; + policies_removed: { policy_id: string; policy_name: string; policy_type: string }[]; +} + +export interface ReportTrendPoint { + report_id: string; + generated_at: string; + score_pct: number | null; + nodes: number; + nodes_unreachable: number; + results: number; + results_stale: number; + by_status: ReportStatusCounts; +} + +export async function fetchReportCapabilities(): Promise { + return apiRequest("/api/v1/ext/reports/capabilities", { headers: authHeaders() }); +} + +export async function fetchReportControls(id: string): Promise { + return apiRequest(`/api/v1/ext/reports/reports/${encodeURIComponent(id)}/controls`, { headers: authHeaders() }); +} + +export async function fetchControlFindings(id: string, framework: string, control: string, failingOnly = false): Promise { + return apiRequest( + `/api/v1/ext/reports/reports/${encodeURIComponent(id)}/controls/findings${buildQuery({ framework, control, failing: failingOnly || undefined })}`, + { headers: authHeaders() }, + ); +} + +export async function setReportPinned(id: string, pinned: boolean): Promise { + return apiRequest(`/api/v1/ext/reports/reports/${encodeURIComponent(id)}/pin`, { + method: pinned ? "POST" : "DELETE", + headers: authHeaders(), + }); +} + +export async function compareReports(targetId: string, baseId: string): Promise { + return apiRequest( + `/api/v1/ext/reports/reports/${encodeURIComponent(targetId)}/compare${buildQuery({ base: baseId })}`, + { headers: authHeaders() }, + ); +} + +/** Daily summaries of the fleet (no group) or of one node group. */ +export async function fetchComplianceTrend(days = 30, group?: string): Promise { + return apiRequest(`/api/v1/ext/reports/trend${buildQuery({ days, group })}`, { headers: authHeaders() }); +} + +/* ── Bor Enterprise programme (shown when the server advertises the capability) ── */ + +export type ScheduleCadence = "daily" | "weekly" | "monthly"; + +export interface ReportSchedule { + id: string; + name: string; + enabled: boolean; + all_nodes: boolean; + node_group_ids: string[]; + policy_types: string[]; + include_items: boolean; + refresh: boolean; + cadence: ScheduleCadence; + at_minute: number; + weekday?: number; + month_day?: number; + time_zone: string; + label_template: string; + notes: string; + webhook_url: string; + webhook_secret_set: boolean; + email_to: string[]; + attach_formats: string[]; + created_by: string; + created_at: string; + updated_at: string; + next_run_at?: string; + running_since?: string; + last_run_at?: string; + last_status?: string; + last_error?: string; + last_report_id?: string; + last_delivery_status?: string; + last_delivery_error?: string; +} + +export interface ScheduleInput { + name: string; + enabled: boolean; + all_nodes: boolean; + node_group_ids: string[]; + policy_types: string[]; + include_items: boolean; + refresh: boolean; + cadence: ScheduleCadence; + at_minute: number; + weekday?: number; + month_day?: number; + time_zone: string; + label_template: string; + notes: string; + webhook_url: string; + webhook_secret?: string; + clear_webhook_secret?: boolean; + email_to: string[]; + attach_formats: string[]; +} + +export interface SchedulesResponse { + items: ReportSchedule[]; + delivery: { email: boolean; http_webhooks: boolean; formats: string[] }; +} + +export interface ScheduleRunOutcome { + status: string; + error?: string; + report_id?: string; + delivery_status?: string; + delivery_error?: string; +} + +const EXT_REPORTS = "/api/v1/ext/reports"; + +export async function listSchedules(): Promise { + return apiRequest(`${EXT_REPORTS}/schedules`, { headers: authHeaders() }); +} + +export async function createSchedule(body: ScheduleInput): Promise { + return apiRequest(`${EXT_REPORTS}/schedules`, { method: "POST", headers: authHeaders(), body: JSON.stringify(body) }); +} + +export async function updateSchedule(id: string, body: ScheduleInput): Promise { + return apiRequest(`${EXT_REPORTS}/schedules/${encodeURIComponent(id)}`, { method: "PUT", headers: authHeaders(), body: JSON.stringify(body) }); +} + +export async function deleteSchedule(id: string): Promise { + await apiRequest(`${EXT_REPORTS}/schedules/${encodeURIComponent(id)}`, { method: "DELETE", headers: authHeaders() }); +} + +export async function runSchedule(id: string): Promise { + return apiRequest(`${EXT_REPORTS}/schedules/${encodeURIComponent(id)}/run`, { method: "POST", headers: authHeaders() }); +} + +export interface Waiver { + id: string; + policy_id: string; + policy_name: string; + policy_type: string; + node_id?: string; + node_name?: string; + schema_id?: string; + key?: string; + reason: string; + approved_by: string; + approved_at: string; + expires_at: string; + revoked_at?: string; + revoked_by?: string; + /** active | expired | revoked */ + state: string; +} + +export interface WaiverInput { + policy_id: string; + node_id?: string; + schema_id?: string; + key?: string; + reason: string; + expires_at: string; +} + +export async function listWaivers(includeInactive = false): Promise<{ items: Waiver[]; max_days: number }> { + return apiRequest<{ items: Waiver[]; max_days: number }>(`${EXT_REPORTS}/waivers${includeInactive ? "?all=1" : ""}`, { headers: authHeaders() }); +} + +export async function createWaiver(body: WaiverInput): Promise<{ id: string }> { + return apiRequest<{ id: string }>(`${EXT_REPORTS}/waivers`, { method: "POST", headers: authHeaders(), body: JSON.stringify(body) }); +} + +export async function revokeWaiver(id: string): Promise { + await apiRequest(`${EXT_REPORTS}/waivers/${encodeURIComponent(id)}`, { method: "DELETE", headers: authHeaders() }); +} + +export interface Attestation { + id: string; + decision: "approved" | "rejected"; + comment: string; + attested_by: string; + attested_at: string; + key_id: string; + /** valid | invalid */ + signature: string; +} + +export interface AttestationSummary { + report_id: string; + /** pending | approved | rejected */ + state: string; + approvals: number; + required_approvals: number; + generated_by_user_id?: string; + items: Attestation[]; + /** The requesting user generated the report (and cannot attest it). */ + self_generated: boolean; + /** The requesting user has already attested the report. */ + self_attested: boolean; +} + +export async function fetchAttestations(reportId: string): Promise { + return apiRequest(`${EXT_REPORTS}/attestations/${encodeURIComponent(reportId)}`, { headers: authHeaders() }); +} + +export async function attestReport(reportId: string, decision: "approved" | "rejected", comment: string): Promise { + return apiRequest(`${EXT_REPORTS}/attest/${encodeURIComponent(reportId)}`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ decision, comment }), + }); +} + +export async function fetchReportPolicies(id: string): Promise { + return apiRequest(`/api/v1/ext/reports/reports/${encodeURIComponent(id)}/policies`, { + headers: authHeaders(), + }); +} + +export async function deleteReport(id: string): Promise { + await apiRequest(`/api/v1/ext/reports/reports/${encodeURIComponent(id)}`, { + method: "DELETE", + headers: authHeaders(), + }); +} + +/** Download a report in one format (json, csv with a part, jws, or a renderer format) as a file. */ +export async function downloadReport(id: string, format: string, part?: string): Promise { + const query = buildQuery({ format, part }); + const res = await fetch(`/api/v1/ext/reports/reports/${encodeURIComponent(id)}/download${query}`, { + credentials: "same-origin", + headers: authHeaders(), + }); + if (!res.ok) { + let detail = res.statusText; + try { + const b = await res.json(); + if (b.error) detail = b.error; + } catch { + /* swallow */ + } + throw new Error(detail); + } + const blob = await res.blob(); + const disposition = res.headers.get("Content-Disposition") ?? ""; + const match = disposition.match(/filename="?([^";]+)"?/); + const filename = match?.[1] ?? `compliance-report.${format === "csv" ? "csv" : format}`; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + return filename; +} diff --git a/server/web/frontend/src/views/Compliance/CompliancePage.tsx b/server/web/frontend/src/views/Compliance/CompliancePage.tsx index 77e05376..b8cd58a5 100644 --- a/server/web/frontend/src/views/Compliance/CompliancePage.tsx +++ b/server/web/frontend/src/views/Compliance/CompliancePage.tsx @@ -3,6 +3,7 @@ // Copyright (C) 2026 Bor contributors import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { useNavigate } from "react-router"; import { PageSection, Title, @@ -24,9 +25,11 @@ import { } from "@patternfly/react-core"; import { Table, Thead, Tr, Th, Tbody, Td, ExpandableRowContent, ThProps } from "@patternfly/react-table"; import SyncAltIcon from "@patternfly/react-icons/dist/esm/icons/sync-alt-icon"; +import FileAltIcon from "@patternfly/react-icons/dist/esm/icons/file-alt-icon"; import { LiveAlert } from "../../components/LiveAlert"; import { BorEmptyState } from "../../components/BorEmptyState"; +import { hasPermission, hasFeature, FEATURE_COMPLIANCE_REPORTS } from "../../apiClient/permissions"; import { fetchCompliancePaged, fetchDConfSchemas, @@ -132,12 +135,13 @@ function buildSummaryIndex(schemas: DConfSchema[]): Map { /* ── per-item breakdown sub-table ── */ -interface ItemsTableProps { +export interface ItemsTableProps { items: ComplianceItem[]; summaryIndex: Map; } -const ItemsTable: React.FC = ({ items, summaryIndex }) => ( +/** Per-item breakdown of one compliance result; also used by the report pages. */ +export const ItemsTable: React.FC = ({ items, summaryIndex }) => ( @@ -197,7 +201,7 @@ const ItemsTable: React.FC = ({ items, summaryIndex }) => ( {item.controls?.length ? (
{item.controls.map((c) => ( -