On-prem hardening guide
A CIS-style baseline for an on-premises or air-gapped deployment. Each control names the setting that enforces it, so the sections map onto a CIS Benchmark or STIG checklist.
The assumed threat model: no outbound egress from the application tier, and mutual-TLS client authentication at the edge. If yours is looser, the controls still apply: the recommended values are just stricter than you need.
Set variables in deploy/.env (Compose maps them into the container) and restart the API. There is a verification checklist at the end.
1. TLS termination
RootTrace supports two TLS topologies. Pick one; do not run cleartext HTTP on any listener reachable outside localhost.
1.1 TLS at the shipped Compose proxy
The supported Compose stack mounts deploy/nginx/reverse-proxy.conf. It terminates TLS before traffic reaches the API and enables server authentication by default:
| Directive | Value | Effect |
|---|---|---|
ssl_protocols | TLSv1.3 | TLS 1.3 only; no downgrade to 1.2/1.1/1.0. |
ssl_certificate | /etc/ssl/roottrace/host.crt | Server certificate mounted from deploy/certs/host.crt. |
ssl_certificate_key | /etc/ssl/roottrace/host_key.pem | Server key mounted from deploy/certs/host_key.pem. |
ssl_session_tickets | off | No session-ticket key reuse risk. |
Strict-Transport-Security | one year, including subdomains | Browser HTTPS enforcement. |
location / | proxy_pass http://roottrace:8090/ | The API remains private on the Compose application network. |
Operator responsibilities:
- Install the server keypair under
deploy/certs/and keep the host private key mode0600. - For mutual TLS, add
ca.cert.pemandca.crl.pemto that directory, uncomment the fourssl_client_*directives and the$ssl_client_verifygate in the shipped proxy, then test valid, expired, revoked, and missing client certificates. Keep the CRL current. - Client-certificate verification is a transport gate, not a RootTrace role or user allowlist. Keep application authentication and authorization enabled.
- The proxy forwards
X-Forwarded-ForandX-Real-IP. KeepROOTTRACE_FORWARDED_ALLOW_IPSandROOTTRACE_TRUSTED_PROXY_CIDRSaligned withROOTTRACE_NGINX_IP. - The Compose proxy uses the nginx image's normal access/error logs. Configure the site's container log driver or log collector to retain and forward them;
nginx/nginx.confis not mounted by the Compose stack.
Keep the app's own TLS (§1.2) disabled in this topology. Compose exposes only nginx and keeps the API on its private networks.
The repository also carries nginx/reverse-proxy.conf, a standalone mTLS reference for a proxy on the same host as the API. It uses different certificate paths and a localhost upstream, so translate both deliberately rather than copying it over the shipped config.
1.2 Direct TLS termination in the app (uvicorn)
For installs without a reverse proxy, terminate TLS in the app itself. main.py passes these straight to uvicorn's --ssl-certfile / --ssl-keyfile when both are set:
| Control | Setting | Recommended value |
|---|---|---|
| Server certificate | ROOTTRACE_SSL_CERTFILE | Absolute path to PEM cert chain. |
| Server private key | ROOTTRACE_SSL_KEYFILE | Absolute path to PEM key (mode 0600). |
| Bind address | ROOTTRACE_LISTEN_HOST | The specific interface, not 0.0.0.0, if the host is multi-homed. |
| Bind port | ROOTTRACE_LISTEN_PORT | Default 8090. |
Note: direct termination does not provide mutual-TLS client-cert enforcement or a CRL gate. For an air-gapped, high-assurance install, prefer §1.1. If you must use §1.2, enforce network reachability and client authentication by other means (IP allowlisting §9, service-account scoping §10).
2. Disable all egress (no call-home)
An air-gapped install must make no outbound connection. Configure and verify each path explicitly.
| Egress path | Setting | Hardened value | Notes |
|---|---|---|---|
| Cloud email relay | ROOTTRACE_ON_PREM_EMAIL_RELAY_ENABLED | false (default) | Default is now false, so no on-prem install calls ROOTTRACE_CLOUD_EMAIL_RELAY_URL unless the operator opts in. Leave it off; use SMTP (§3). |
| Embedding / vector model | ROOTTRACE_EMBEDDING_OFFLINE | true | Defaults to true on on-prem installs. Forces local embedding instead of any hosted embedding call. |
| Semantic search backend | ROOTTRACE_ELASTICSEARCH_HOST | "" (empty) unless a local ES cluster is reachable | Empty (the default) cleanly disables AI/semantic search rather than dialing a host that may not exist. Set only to an in-enclave ES endpoint. |
| LLM provider | ROOTTRACE_LLM_PROVIDER | disabled, or openai_compatible for an in-enclave model server | disabled makes no LLM calls. To keep AI features without external egress, use openai_compatible with the settings below. |
| OpenAI-compatible LLM | ROOTTRACE_LLM_OPENAI_BASE_URL / ROOTTRACE_LLM_OPENAI_MODEL | In-enclave server URL and model name | The URL must be reachable from the API container or pod. Leave ROOTTRACE_LLM_OPENAI_API_KEY empty unless the server requires a bearer token. |
| Anthropic API base | ROOTTRACE_CLAUDE_BASE_URL | In-enclave gateway URL, or leave empty with no key | Overrides the default api.anthropic.com base so requests never leave the enclave. |
| Vendor marketing surfaces | ROOTTRACE_VENDOR_MARKETING_ENABLED | false for OEM/air-gapped | Suppresses vendor marketing UI/email surfaces; does not itself make calls but keeps the install self-contained. |
The air_gapped profile rejects an enabled vendor email relay and online embedding mode. It does not classify every arbitrary URL as internal or external. Review the effective configuration and enforce the boundary below.
On-prem password flows never call HaveIBeenPwned, regardless of ROOTTRACE_BREACHED_PASSWORD_CHECK. That variable controls only hosted-cloud screening and is not an on-prem egress control.
The table above covers the server. The agents that report into it have their own destination, and it is the one an air-gapped install is most likely to get wrong:
| Agent | Setting | Hardened value | Notes |
|---|---|---|---|
| Collector | ROOTTRACE_API_URL | This deployment's own /api base | Required. The collector has no default destination and exits rather than start without one. |
| APM SDKs | api_url / apiUrl, or ROOTTRACE_API_URL in the service process | This deployment's own /api base | Must be set explicitly. The published SDKs default to RootTrace Cloud; an instrumented service given only a token will attempt to reach it. |
| Server self-monitoring | ROOTTRACE_API_URL | Loopback /api base (Compose default) | Flushes to its own listener; the server never inherits the SDK's cloud default. |
Belt-and-braces: enforce egress denial at the network layer too (default-deny egress firewall / no default route out of the enclave). Application settings are the second line of defense, not the only one.
3. SMTP-only email
With the cloud relay off (§2), outbound notification/auth email must go through your own SMTP server inside the enclave. Both the global sending switch and a configured SMTP host are required.
| Control | Setting | Recommended value |
|---|---|---|
| Global switch | ROOTTRACE_EMAIL_SENDING_ENABLED | true only after the relay is ready. |
| SMTP host | ROOTTRACE_SMTP_HOST | Your in-enclave relay. |
| SMTP port | ROOTTRACE_SMTP_PORT | 587 (submission, default) or 465 (implicit TLS). |
| Username / password | ROOTTRACE_SMTP_USERNAME / ROOTTRACE_SMTP_PASSWORD | Dedicated service credential supplied through the deployment's protected environment configuration. |
| STARTTLS | ROOTTRACE_SMTP_STARTTLS | true (default) for port 587. |
| Implicit TLS | ROOTTRACE_SMTP_SSL | true if using port 465; otherwise false. |
| Timeout | ROOTTRACE_SMTP_TIMEOUT_SECONDS | Default 15; tune to your relay. |
Do not configure SendGrid (ROOTTRACE_SENDGRID_API_KEY) on an air-gapped install: that is a cloud egress path. Always require TLS to the relay (ROOTTRACE_SMTP_STARTTLS=true or ROOTTRACE_SMTP_SSL=true); never send credentials over cleartext SMTP.
4. FIPS posture
For a FIPS-validated cryptographic posture:
| Control | Setting | Recommended value | Notes |
|---|---|---|---|
| Security baseline | ROOTTRACE_SECURITY_PROFILE | regulated or air_gapped | Validates the regulated application baseline at startup. Production defaults to regulated; network egress still needs an external control. |
| FIPS mode | ROOTTRACE_FIPS_MODE | true | Requires an active FIPS OpenSSL provider. Startup fails if the provider cannot be verified. Default false. |
| JWT signing algorithm | ROOTTRACE_JWT_ALGORITHM or platform-admin Flutter selection | RS256 or ES256 | The database selection overrides the environment default immediately. FIPS mode accepts only these algorithms. The update rotates the database signing key before returning, and every API replica reads the active keys from MongoDB. |
Run the host on a FIPS-validated OpenSSL module (OS-level FIPS mode enabled) so that the Python crypto stack and TLS both operate within the validated boundary. ROOTTRACE_FIPS_MODE=true verifies that boundary instead of merely advertising it. A bad provider or algorithm stops the service.
Outside that validated-provider mode, leave JWT signing at the default ML-DSA-65. It is the post-quantum signature scheme standardized by NIST FIPS 204 and registered for JOSE by RFC 9964. Account for its larger token signatures when setting proxy and gateway header limits.
5. Master-secret externalization (KMS / Vault)
The master application secret must never be baked into an image or committed. Source it externally:
| Control | Setting | Recommended value |
|---|---|---|
| Inline secret value | ROOTTRACE_APP_SECRET | Avoid for high-assurance; leave unset if using a file. |
| Mounted secret file | ROOTTRACE_APP_SECRET_FILE | Path to a KMS/Vault/Kubernetes-projected secret file. |
Recommended pattern:
- Store the secret in Vault (or your KMS) and project it into the container as a mounted file, then point
ROOTTRACE_APP_SECRET_FILEat that path. The value never appears in the environment, image layers, or process listing. - For a custom orchestrator projection, restrict the mounted file to mode
0400, owned by the app user. - Rotate via your secret manager; the app derives JWT-key encryption material from the app secret (
app_secrets), so plan a rotation window. - Prefer
ROOTTRACE_APP_SECRET_FILEoverROOTTRACE_APP_SECRET: an env-var value is visible to anything that can read/proc/<pid>/environ.
The supported Compose stack copies ROOTTRACE_APP_SECRET_HOST_PATH through a network-disabled initializer and mounts the private Docker-volume copy at /run/secrets/app_secret. Keep the host source mode 0600 inside the mode 0700 deploy/secrets directory. Do not make it world-readable, and do not set a container-only path that is not mounted.
6. STIG session and access controls
These all ship in the product. Enable them for a hardened install. The SRG identifiers are DISA Application Security Requirements Guide references.
| Control (SRG) | Setting | Hardened value | Notes |
|---|---|---|---|
| Workspace baseline | Workspace setting security_profile | regulated or air_gapped | Applies the conservative DISA STIG, FedRAMP, ISO 27001, and SOC 2-aligned tenant defaults shown in the workspace security UI. |
| Privileged MFA (SRG-APP-000149/000151) | ROOTTRACE_REQUIRE_PRIVILEGED_MFA | true (default) | Admin/owner/platform-admin accounts must enroll an MFA factor; a privileged account without one gets an enrollment-only session. Local owners should use TOTP because it does not depend on email. |
| Concurrent-session cap (SRG-APP-000001) | Workspace setting max_concurrent_sessions | e.g. 3 (0 = unlimited) | Per-account concurrent session limit, set per organization; range 0–50. Counts only sessions still inside the idle window (see below). |
| Idle-session timeout (SRG-APP-000295) | ROOTTRACE_SESSION_IDLE_TIMEOUT_MINUTES (global) / session_idle_timeout_minutes (per-org) | e.g. 15 | Terminates an inactive session at next use. Default 0 (disabled) so wall-mounted dashboards are not logged out; set explicitly for privileged environments. |
| Account lockout (SRG-APP-000065) | ROOTTRACE_LOGIN_LOCKOUT_THRESHOLD / ROOTTRACE_LOGIN_LOCKOUT_MINUTES | e.g. 3 / 15 | Locks after N consecutive failed logins (min 3) for M minutes (min 1). Self-recovering; a completed password reset clears it. Also settable per-org. |
| Inactive-account disable (SRG-APP-000163/000705) | ROOTTRACE_ACCOUNT_INACTIVITY_DISABLE_DAYS / ROOTTRACE_INACTIVE_ACCOUNT_SWEEP_HOURS | 35 (DoD baseline) / 24 | Auto-disables accounts idle beyond the threshold. Default 0 (opt-in). |
| Logon banner (SRG-APP-000068/000069) | Workspace settings logon_banner_enabled / logon_banner_text | Enabled, with your Standard Mandatory Notice and Consent text (≤5000 chars) | Shown on the workspace sign-in page. |
| Access-token lifetime | ROOTTRACE_ACCESS_TOKEN_MINUTES | Keep short (default 60; capped at 5 days) | Shorter lifetimes reduce stolen-token exposure. |
| Refresh-token lifetime | ROOTTRACE_REFRESH_TOKEN_DAYS | Default/max 5 | Bounded refresh horizon. |
The per-org settings (max_concurrent_sessions, session_idle_timeout_minutes, login_lockout_*, account_inactivity_disable_days, logon_banner_*) are set through the Flutter workspace security settings and override/refine the global env defaults. The page also includes security_profile and privileged-MFA enforcement.
How the two session controls interact
A session is terminated at exactly two points: when it exceeds the idle window, and when a new sign-in pushes the account past its concurrent cap. Nothing else ends an active session.
The idle timeout is enforced when a token is next presented, which means a device someone simply stopped using is never seen again and its session would otherwise sit there indefinitely. Those sessions are therefore swept at the next sign-in and do not count toward the concurrent cap. Otherwise three laptops closed last week would fill a 3-session cap and signing in on a fourth device would evict the phone in your hand. The cap bounds concurrent sessions; a session inactivity already ended is not one.
A session with no recorded activity at all is never swept on that basis: absent metadata is unknown age, not proven idleness.
When either control ends a session, the reason is recorded and returned on the next request from that device ("Session terminated due to inactivity", or the concurrent-session-limit message) rather than a generic token rejection. Both are also audit events. Eviction always removes the least-recently-used session, never the one signing in, so a user cannot be locked out by the cap.
FIPS provider state, JWT/WebAuthn policy, token lifetimes, secrets and metrics authentication, rate limits, audit chaining, request limits, offline embeddings, email paths, Elasticsearch TLS, proxy/CDN trust, origins, egress exceptions, collector-token lifetime, and direct API TLS are deployment boundaries. Workspace admins can verify their effective values in the same Flutter page, but cannot mutate process-wide runtime state for neighboring workspaces. Platform admins can select the deployment security profile and JWT signing algorithm in Flutter without restarting the API. The profile is accepted only when the running process configuration satisfies it. JWT changes rotate the database signing key immediately and are observed by every API replica.
6.1 Workspace-mandated SSO (sso_required): per-workspace enforcement
Setting a workspace's login mode to sso_required (workspace login settings; requires at least one enabled SSO method: OIDC, SAML, or LDAP) is enforced at two layers:
- At sign-in: password login, password reset, and password-based invite acceptance are refused for accounts whose every workspace mandates SSO (account-wide block, kept as defense in depth).
- At workspace access: every token records how the session authenticated (an RFC 8176
amrclaim:pwd,otp,mfa, orfederatedfor OIDC/SAML/LDAP sign-ins; refresh carries it forward unchanged). Every org-scoped request re-checks it: a session that did not come through an identity provider gets403on ansso_requiredworkspace, even if the account also belongs to password-capable workspaces. This closes the mixed-workspace gap where a password session could previously reach an SSO-mandated workspace.
Tokens minted before the amr claim existed are treated as password-only, so sso_required workspaces reject them immediately; affected SSO users simply sign in again through their IdP. Machine credentials (service-account tokens, collector tokens, SCIM) are exempt: they never authenticate through an IdP and their access is bounded by their own scopes.
6.2 MFA recovery (backup) codes
Enrolling the account's first MFA factor (TOTP, email, or WebAuthn) issues 10 single-use recovery codes, returned in plaintext exactly once; only HMAC hashes are stored. A recovery code can complete an MFA challenge in place of the TOTP/email/passkey proof (same attempt limits), consuming it permanently. POST /api/auth/mfa/backup-codes/regenerate (fresh authenticator, email, or passkey proof required; an existing recovery code also works for a passkey-only account) invalidates the old set and mints a new one; disabling the last MFA factor clears the stored codes. Every generation, consumption (with codes_remaining), and regeneration is audit-logged.
7. Password policy
| Control | Setting | Hardened value | Notes |
|---|---|---|---|
| Complexity enforcement | ROOTTRACE_PASSWORD_REQUIRE_COMPLEXITY | true | Shipped on-prem policy requires at least 12 characters with an uppercase letter, lowercase letter, digit, and symbol. |
Passwords are hashed with PBKDF2-SHA512 and a per-password salt (see docs/security.md). On-prem breached-password screening is disabled in code; use complexity, length, MFA, lockout, and password history as the local controls.
8. Audit hash-chain and SIEM/syslog export
RootTrace maintains a tamper-evident audit trail and can forward it to your SIEM.
| Control | Setting | Hardened value | Notes |
|---|---|---|---|
| Hash-chained audit log | ROOTTRACE_AUDIT_HASH_CHAIN_ENABLED | true (default) | Each audit event is chained to the prior one (optimistic-concurrency ordered) so tampering is detectable. |
| Syslog forwarding | ROOTTRACE_AUDIT_SYSLOG_ENABLED | true | Mirrors each audit event to an external collector as RFC 5424 syslog. |
| Syslog host / port | ROOTTRACE_AUDIT_SYSLOG_HOST / ROOTTRACE_AUDIT_SYSLOG_PORT | Your in-enclave SIEM collector / 514 | Point at the SIEM ingest endpoint. |
| Syslog protocol | ROOTTRACE_AUDIT_SYSLOG_PROTOCOL | tcp preferred | udp (default) or tcp; prefer tcp for delivery reliability. |
| Audit retention | ROOTTRACE_AUDIT_RETENTION_DAYS | 365+ (default 365, min 30) | Audit history outlives telemetry retention; auditors expect ~1 year. |
| On-demand SIEM export | roottrace_audit_export module: GET /api/audit/export | n/a | Streams the caller's organization audit events for SIEM ingest/attestation. |
Forward audit events to a SIEM that is independent of the RootTrace host so the chain and the copy cannot be altered together. Combine with the nginx access log (§1.1) for full request-level attribution.
9. IP allowlisting
The roottrace_ip_allowlist module enforces per-organization CIDR/host allowlists (GET/mutation endpoints under /api/organizations).
- Configure each organization's allowlist with the CIDR ranges (or bare host addresses) that legitimately reach the workspace. A non-empty allowlist causes
enforce_ip_allowlistto return403for any client IP outside it. The check runs again after tenant resolution, so routes that infer a workspace from a resource ID or default membership cannot bypass it. - The same policy covers user JWTs, service-account tokens, SCIM requests, and collector/OTLP ingest.
- Allowlist database, import, or enforcement failures return
503. The control fails closed instead of silently widening access. - Default is deliberately permissive for backward compatibility: an empty or absent allowlist means allow-all. For a hardened install, set a non-empty allowlist on every organization.
- This relies on the true client IP, so run behind the reverse proxy with
--proxy-headers(§1.1) or the allowlist will see the proxy's address. - Treat IP allowlisting as defense-in-depth behind mTLS (§1.1) and network segmentation, not as the sole access gate.
Outbound destination allowlisting
Notification webhooks and repository SSH links are server-side network clients. Their destinations are public-only by default. Private, loopback, link-local, reserved, and metadata addresses are rejected, including addresses reached after an HTTP redirect.
If an on-prem integration must reach an internal service, list only its service subnet:
ROOTTRACE_OUTBOUND_ALLOWED_PRIVATE_CIDRS=10.40.8.0/24This setting does not enable plaintext HTTP. Webhooks additionally require ROOTTRACE_NOTIFICATION_ALLOW_INSECURE_WEBHOOKS=true for HTTP or unverified TLS. Repository SSH resolves before each operation and pins the checked address to close the DNS-rebinding window.
10. Service-account scoping
Machine-to-machine access uses scoped service-account tokens (roottrace_service_accounts) rather than user credentials.
- Tokens are org-scoped and explicitly scoped to a capability set:
read,write,scim(VALID_SERVICE_ACCOUNT_SCOPES). The default isreadonly. Grantwrite/scimonly when required. - Grant least privilege: a telemetry-ingest integration needs
write; a read-only exporter needs onlyread; reservescimfor the identity provisioning integration that drivesroottrace_scim(/scim/v2). - Set an expiry (
expires_in_days) on every token;0/omitted means non-expiring. Avoid non-expiring tokens in a hardened install. - Tokens are hashed at rest (HMAC), returned once, and independently revocable. Rotate on a schedule and revoke on personnel/integration change.
- An unknown scope string is rejected (
422) rather than silently ignored, so typos fail loudly.
11. Collector-token TTL and rotation
Collector enrollment tokens ingest telemetry from agents; bound their lifetime.
| Control | Setting | Hardened value | Notes |
|---|---|---|---|
| Token TTL | ROOTTRACE_COLLECTOR_TOKEN_TTL_DAYS | e.g. 90 (0 = never expire) | Default 0 (non-expiring) is the legacy behavior; set a finite TTL so stale agent tokens die on their own. |
| Rotation overlap | ROOTTRACE_COLLECTOR_TOKEN_ROTATION_OVERLAP_HOURS | 24 (default) | During rotation, already-deployed agents keep working for this overlap window using the outgoing token, so rotation is zero-downtime. |
Operational guidance:
- Collector tokens are scoped to one organization and one environment, returned once, hashed at rest, and revocable (
docs/security.md). - Collectors are outbound-only and never accept inbound command execution; default mode is
read_only. - Rotate on the TTL cadence: issue the new token, let the overlap window elapse while agents pick it up, then revoke the old one.
12. Health and metrics endpoints
The roottrace_metrics module exposes /metrics (scrape) and /readyz (readiness/health) with no third-party dependencies. Set a high-entropy ROOTTRACE_METRICS_BEARER_TOKEN; Prometheus must send it as a bearer token. Outside development, /metrics returns 404 when the setting is empty and 401 for a missing or incorrect token.
/readyz and /healthz/ stay unauthenticated for orchestrator probes. Restrict them to the monitoring subnet at the reverse proxy or firewall.
The API also rejects oversized bodies before parsing. Keep ROOTTRACE_API_MAX_REQUEST_BODY_BYTES at its 16 MiB default unless a documented API payload requires more. The two NDJSON collector streams use ROOTTRACE_API_MAX_STREAM_BODY_BYTES (64 MiB by default).
Quick verification checklist
- [ ] Profile:
ROOTTRACE_SECURITY_PROFILE=regulated(orair_gapped) and startup validation passes. - [ ] TLS: shipped Compose proxy (
deploy/nginx/reverse-proxy.conf), with its optional mTLS controls enabled when required, orROOTTRACE_SSL_CERTFILE/ROOTTRACE_SSL_KEYFILEset; no cleartext listener. - [ ] Egress: private destinations blocked or narrowly listed in
ROOTTRACE_OUTBOUND_ALLOWED_PRIVATE_CIDRS;ROOTTRACE_ON_PREM_EMAIL_RELAY_ENABLED=false,ROOTTRACE_EMBEDDING_OFFLINE=true, noROOTTRACE_CLAUDE_API_KEY/ANTHROPIC_API_KEY; egress firewall default-deny. - [ ] Agents: every collector and every instrumented service points at this deployment's own
/apibase. The SDKs need this set explicitly: their default is RootTrace Cloud. - [ ] Email: either disabled, or
ROOTTRACE_EMAIL_SENDING_ENABLED=truewithROOTTRACE_SMTP_HOSTset and TLS enabled; SendGrid unset. - [ ] FIPS:
ROOTTRACE_FIPS_MODE=true,ROOTTRACE_JWT_ALGORITHM=RS256orES256, and the validated OpenSSL FIPS provider is active. - [ ] Secret: no inline value; Compose host source
0600inside a0700directory, or custom-orchestrator projection0400owned by the app user. - [ ] STIG:
ROOTTRACE_REQUIRE_PRIVILEGED_MFA=true, idle timeout, lockout, concurrent-session cap, inactivity disable, logon banner all set. - [ ] Audit: hash chain on, syslog export to SIEM, retention ≥365d,
/api/audit/exportreachable to auditors. - [ ] Access: non-empty IP allowlist per org; scoped, expiring service-account tokens; finite collector-token TTL with rotation overlap.
- [ ] Metrics:
ROOTTRACE_METRICS_BEARER_TOKENstored as a secret;/metrics,/readyz, and/healthz/restricted to the monitoring subnet.