Guardrails & Security
An AI that can run code as SYSTEM/root across a fleet is a serious risk surface. These controls are built in.
Human-in-the-loop (enforced in code)
Section titled “Human-in-the-loop (enforced in code)”- AI actions go through proposal tools (
propose_script, bulk deploy, or high-level action tools), which always setrequires_approval=true→ job statusawaiting_approval. This applies to both the built-in chat and the MCP surface (external AI like Claude/ChatGPT) — same permission-filtered tool catalog, same gate. - Configuration is proposed too.
propose_task,propose_checkandpropose_policywrite the row disabled, stamped withproposed_by. The scheduler selectsWHERE enabled, so a proposal never runs; a person enables it in the console, and that act is the review. See Configuration proposals below. - Every ad-hoc script run requires approval by default.
POST /api/jobsandrun-scriptignorerequire_approval:falseunless the deployer explicitly setsRMM_ALLOW_UNAPPROVED_JOBS=true. - A job in
awaiting_approvalis never dispatched. OnlyPOST /api/jobs/{id}/approve(a human + bearer token) moves it topendingand triggers dispatch. - The UI must show the full script (dry-run preview) before the approve action.
Destructive actions are permission-gated, human-only
Section titled “Destructive actions are permission-gated, human-only”- Deleting a device or group requires
devices.deleteorclients.managerespectively (DELETE /api/devices/{id},DELETE /api/groups/{id}) — a deliberate human action in the tool, audited (device.delete/group.delete). The AI/MCP surface can only propose moving or deleting devices (propose_move_devices,propose_delete_devices); nothing changes until a person approves the proposal with their own permissions and scope, and a deletion needs the device count typed. It has no tool for groups, sites or companies. See 06-ai-chat. - Scheduled tasks are the one pre-approved path: the scheduler dispatches a
due task through
jobs.RunAutomated, which creates the job already approved. They requiretasks.manage, are authorized at creation and fully audited; disable a task to stop it. Because that path skips approval by design, a task the AI authored may not reach it until a person has enabled it — see below.
Configuration proposals (the second gate)
Section titled “Configuration proposals (the second gate)”Letting the AI create a task would have undone the gate above, and it is worth being explicit about why. A task is a script with a schedule, and the scheduler runs one pre-approved and immediately, forever. An AI able to create an enabled task would therefore have been strictly more powerful than one able to propose a script: the same arbitrary code, with no approval, repeating.
So configuration reuses the switch that already existed:
propose_task/propose_check/propose_policywrite the row withenabled = falseand setproposed_byto the proposing actor.- The scheduler’s own queries already read
WHERE enabled, so a proposal is inert with no scheduler change at all. - A person reviews it on the existing Tasks / Checks / Policies screens, which
badge it as an AI proposal, and enabling it records
approved_by.
Two columns, because they answer different questions. proposed_by is permanent
provenance and survives acceptance — months later the row still says an AI wrote
it. approved_by names who released it. “Awaiting review” is therefore
proposed_by <> '' AND approved_by = ''.
A CHECK constraint refuses to enable a row that is proposed but not approved. That guard lives in the database rather than in the tool, because the REST handlers are a second path into the same rows, and a guard written in only one of them is exactly the bug the column exists to prevent.
Patches: install yes, approve never
Section titled “Patches: install yes, approve never”install_patches acts rather than proposes. It is safe for a reason that has
nothing to do with the AI: DispatchInstall calls ClaimApprovedPatches, which
claims only identifiers a person already approved, so an unapproved id installs
nothing. Approving a patch is the human decision about what enters the fleet, and
there is deliberately no tool for it — an assistant that could approve and
then install would make the guarantee circular.
Why the gate does not depend on who is asking
Section titled “Why the gate does not depend on who is asking”Configuration is not created enabled even for an unrestricted administrator. The model reads inventory, hostnames and alert text, all of which come from the monitored machines. “An admin asked” is not evidence that an admin authored the request, so the gate is not conditioned on the caller.
MCP surface controls
Section titled “MCP surface controls”The /mcp endpoint (external AI) adds: a built-in IP allowlist
(RMM_MCP_ALLOWED_IPS), the mcp.use permission, and dedicated revocable
API tokens (issue per connector — never share the bootstrap/admin token). MCP
and the built-in AI Chat advertise and execute only tools allowed by the
principal’s permissions (devices.view, reports.view, patches.view,
jobs.create, jobs.manage, etc.) and access scope. See 11-mcp.
Audit trail
Section titled “Audit trail”Every sensitive transition writes an immutable audit_log row:
job.propose, job.approve, job.reject, job.result, remote.start — with
actor, target, and detail. Surface this in the UI and ship it off-box for
tamper-evidence in production.
Account changes are recorded the same way: user.role.update, user.disable /
user.enable, and user.scopes.update. The last one carries how many scopes the
user had before and after and whether the result is full fleet access —
because the widest grant this API can make is an empty scope list, which is
byte-identical to a restriction that selected nothing. It was the only user
mutation that could hand over the entire fleet without leaving a trace of who
did it.
Retention
Section titled “Retention”Only the machine-written entries are ever purged: job.automated and
job.result. Everything a person did is kept regardless of age.
The split is by what an entry is, not by when it happened, because the two kinds have nothing in common but a table. The human record — who approved a script, who changed a scope, who moved mail delivery — is the reason the log exists, and it is small: measured on a real instance, an audit row costs 550 bytes with its index.
The other two are telemetry, and they scale with the fleet rather than with the
number of people. job.automated is one row per scheduled task, per device, per
run: a tenant with 200 devices and three hourly tasks writes 14,400 a day, about
2.9 GB a year, and job.result doubles it. job.result is also duplicative —
the jobs table already holds status, exit code, stdout and stderr.
RMM_AUDIT_RETENTION_DAYS (default 90) sets the window. 0 disables the purge
entirely, unlike the monitoring knobs which replace zero with a default: an
audit log that deletes nothing is a legitimate configuration and has to be
reachable. Values below 30 are raised to 30 — a shorter window is far more
likely to be a mistake, or someone shortening the trail on purpose, than a real
requirement.
Every purge writes an audit.purge entry recording how many rows went and the
cutoff applied. Without it a gap in the trail would be indistinguishable from
tampering; with it, the log explains its own holes.
Append-only, in three layers
Section titled “Append-only, in three layers”Each covers what the one before it cannot, and the difference is worth being precise about — the obvious single measure is much weaker than it looks.
1. Triggers. audit_log refuses UPDATE outright and refuses DELETE
except through purge_machine_audit(). This stops an application bug and a
hurried manual statement. It does not stop someone holding the credentials
the application uses: an owner can ALTER TABLE ... DISABLE TRIGGER and then do
as they please.
2. Privilege separation. Migration 012 creates an openrmm_app role that can
append to and read audit_log, and cannot update or delete it. Point
RMM_DATABASE_URL at that role and a compromised server cannot erase what it
did — the credentials that could are not in its memory. The purge still works,
through a SECURITY DEFINER function that can only remove aged machine entries.
The role is created without a password, so the migration carries no secret.
Migrations must run separately. Applying one needs ownership of the tables; serving does not — and if the serving process held the owner credential it could drop the triggers and delete the trail, which is the whole thing this prevents. So the owner credential is used for a few seconds and never enters the process that serves HTTP:
RMM_DATABASE_URL=<owner dsn> rmm-server -migrate-only # applies and exitsRMM_DATABASE_URL=<app dsn> rmm-server # servesAn orchestrated deployment should run migrations with the owner’s credentials, configure the restricted application role, and only then start the server. Generate separate database credentials for each installation.
Starting the server as the restricted role with a migration outstanding fails with the command to run, rather than an opaque permission error.
Until layer 2 is switched on, layers 1 and 3 are in force and the server says so at every start.
3. A hash chain. Every human-record entry carries a SHA-256 of its contents
plus the previous entry’s hash, so editing or removing one breaks every link
after it. verify_audit_chain() reports the first break.
This makes tampering evident; it does not prevent it. Whoever can write to the table can recompute the chain. It earns its place next to layer 2 rather than instead of it: with the application unable to delete, a rewrite needs the separate owner credentials, and even then it has to be complete and consistent to go unnoticed. Anchoring the head hash somewhere outside the database would close that last gap; that is not done here.
Only the human record is chained. Machine-written entries are excluded because retention deletes them, and a chain that a routine purge severs would raise an alarm on every housekeeping run — which is an alarm nobody reads.
A failed audit write is counted in rmm_audit_write_failures_total. Any value
above zero means entries were lost — the only signal there is, since a trail
cannot record its own absence.
Authentication
Section titled “Authentication”- API: bearer tokens. The bootstrap
RMM_ADMIN_TOKENshould be a long random secret; rotate it by issuing scopedapi_tokensand retiring the bootstrap. - Users: bcrypt password hashes. Password policy: at least 12 characters,
with an uppercase letter and a symbol, and not the username or a well-known
password. A symbol is anything that is not a letter, a digit or a space —
defined by exclusion so accented and non-Latin keyboards are accepted rather
than rejected by an ASCII allowlist. The rule lives in one place
(
passwordProblem,internal/api/api_auth.go) and its error names the specific failure, so an operator is not left re-reading the policy guessing which part they missed. - A user can change their own password (
POST /api/me/password): the current password is verified, the policy is enforced, the change is audited (user.password.change), and the user’s other sessions are revoked. - Disabling an account (
POST /api/users/{id}/disable,users.manage) is the non-destructive counterpart to deleting one: access stops, the account and its audit trail stay. Live sessions are ended immediately — a disable that leaves an open tab working is not a disable — API tokens bound to that user stop, alert mail stops, and no password reset is issued. Unbound API tokens are unaffected. You cannot disable your own account or the built-inadmin. Deletion still exists and still cascades; prefer disable for a person who left, so what the account did remains on the record. Optional two-factor (TOTP, RFC 6238) per user —internal/auth/totp.go(HMAC-SHA1, ±1 step, constant-time compare). Setup returns a QR/secret; once enabled, login requires acodeand returns401 {totp_required:true}until a valid code is supplied. The TOTP secret is encrypted at rest (see below). Enabling 2FA issues 10 one-time recovery codes (shown once, stored SHA-256-hashed, single-use) usable in place of the TOTP code at login; a user can regenerate them, and a user manager can reset a locked-out user’s 2FA. - TOTP enrollment and rotation use an encrypted pending candidate. Setup requires the current password and, when a factor is already active, its current TOTP. The old factor remains active until a code from the candidate is verified and promotion/recovery-code replacement commits atomically.
- Editable runtime config secrets (AI API key, MeshCentral token key) are
write-only fields gated by
settings.manage; TOTP secrets are stored on the user. Both use AES-256-GCM (enc:v1:prefix,internal/settings) before storage;/api/configreturns only boolean*_setflags for config secrets. - Agents: a random 32-byte token minted at enrollment, stored hashed (SHA-256) server-side; compared in constant time. Enroll tokens are single/limited-use and can expire.
- Remote sessions: short-lived (2 min) random tokens, scoped to one session.
RBAC & per-user access scope
Section titled “RBAC & per-user access scope”- Roles are named permission sets, not a fixed hierarchy. Built-ins are
readonly,tech, andadmin; custom roles are stored inrolesandrole_permissions. The backend returns403when a permission is missing and the UI gates navigation/actions from the samepermissionslist returned by login and/api/me. - Device remote capabilities are split:
devices.remotefor remote desktop,devices.filesfor file transfer, anddevices.terminalfor elevated terminal. The elevated remote Terminal (provider:"terminal") opens a root/SYSTEM shell — equivalent to running arbitrary scripts — so it is separate from remote desktop observation. - Per-user access scope (
user_scopestable): a user is either unrestricted (sees the whole fleet) or restricted to one or more assignments at Company / Site / Department / specific-device granularity. Scope is enforced on both the read path (list endpoints are filtered — devices, jobs, alerts, checks, tasks, check-results, clients, sites) and the write/action path, so a restricted user cannot see or touch out-of-scope devices. The AI/MCP respects the same scope (see06-ai-chat). Chat sessions are owned (created_by) and private to their creator. - An empty scope set means unrestricted, which makes “restrict this user to
nothing” indistinguishable from “give this user everything” in the payload. The
console therefore refuses to save a restricted selection with nothing picked
— granting the fleet has to go through the explicit Full access choice —
and the API audits the empty case as
full_access. The same asymmetry is why losing every scope row to a deleted target disables the account instead (see 03-database-schema). - Delegation cannot widen authority. A scoped user manager cannot create an
unrestricted user, delegate global
admin, assign permissions it lacks, or manage a user whose scopes escape its own. A scoped API-token manager can issue only a permission-subset token bound to itself. Role and scope are orthogonal. - The built-in
readonlyrole includesdevices.remote; it can start remote desktop and is therefore not purely observational. It lacks file transfer and terminal. Use a custom role withoutdevices.remotefor strict observation.
Threat-model notes / hardening TODO
Section titled “Threat-model notes / hardening TODO”These are required deployment controls and known limitations — documented, not silently omitted:
- TLS — terminate HTTPS/WSS at a trusted edge. Tokens and traffic must not
cross the client-facing wire in clear text. Production Compose publishes its
three listeners on loopback by default; source ACLs belong to that TLS edge,
not to UFW rules on loopback backend ports. Prefer the
split-ingress topology (
deploy/README-production.md): three doors on three ports so edge policy can keep the agents door public (agents live on untrusted networks) while the console door is restricted to your IPs/VPN and the MCP door is exposed per your choice. The app still offersRMM_MCP_ALLOWED_IPSas defense in depth. RBAC / multi-tenancy— DONE: default and custom roles with granular permissions plus per-user Company/Site/Department/device access scope, enforced on read and write paths and on the AI/MCP surface. See RBAC & per-user access scope above.- Script sandboxing — scripts run with the agent’s privileges. Consider an allowlist of vetted scripts, signing, or a constrained execution mode for AI-authored scripts.
- Per-user quotas are not implemented. Per-IP limits exist for auth, MCP and the authenticated API, but they are not usage/account quotas.
Relay authorization— DONE: role-specific one-time tickets are principal-bound and supplied only in the WebSocket handshake. End-to-end media encryption remains future work (the relay is still the trust boundary).Agent token rotation and revocation— DONE: revocation fences live control/relay attachment; rotation is single-flight and commits on reconnect.- Output size limits — the reference agent caps stdout and stderr at 1 MiB each. A server-side cap remains desirable for non-reference agent clients.
- Single replica / observability — hub, relay and scheduler are process-local;
there is no supported HA mode.
/healthzis liveness only;/readyzadds a database ping and the token-gated/metricsa small snapshot. There is no tracing or built-in log/backup alerting.
Safe-script guidance for the AI
Section titled “Safe-script guidance for the AI”The system prompt instructs the model to prefer idempotent, non-destructive scripts and to explicitly flag any unavoidable destructive operation. This is guidance, not a guarantee — the human approval gate is the real control.
Security audit (2026-06) — status
Section titled “Security audit (2026-06) — status”An independent audit confirmed the core posture is sound: no SQL injection
(all queries parameterized), the approval guardrail holds (AI/MCP can only
create approval-gated job proposals; deletes are permission-gated and unreachable by AI), RBAC route
permission checks are correct, and tokens/secrets are well handled (256-bit random,
stored hashed; bcrypt passwords; constant-time admin/agent compares; /api/config
exposes no secrets).
Fixed in this pass:
- 5xx responses no longer leak internal error detail (logged server-side, generic message to the client).
RMM_ADMIN_TOKENmust be ≥ 32 chars (rejected at startup otherwise).- Login runs a dummy bcrypt compare for unknown users (mitigates enumeration).
- Agent caps job stdout/stderr at 1 MiB each (DoS / DB-bloat guard).
- API/MCP request bodies are capped at 1 MiB (
http.MaxBytesReader), and the agent/relay WebSockets cap per-message reads (8 MiB / 32 MiB viaSetReadLimit) — a single peer can’t force an unbounded allocation. - Per-IP rate limiting (
httprate): 10/min on/api/auth/*(anti brute-force), 60/min on/mcp, and a safety net across the authenticated API (anti token-guessing) that defaults to 600/min and is tunable viaRMM_API_RATE_LIMIT(the single-page UI polls and fires parallel queries on navigation, so it needs headroom). Returns429, which the frontend retries with backoff. The key respectsRMM_TRUST_PROXY_HEADERS.
Second pass (multi-tenancy / scope):
- A review of the new per-user scope feature found cross-tenant read-path leaks
(list endpoints
/jobs,/alerts,/checks,/tasks,/check-resultsreturned all tenants even when the action path was scoped) and globally readable chats. Both fixed: list endpoints are now scope-filtered and chat sessions are owned by their creator. Sensitive routes are now permission-gated (audit.view,policies.*,clients.*,scripts.*,alerts.manage) and scope-gated where they act on devices. Verified live (readonly→403 for restricted modules, scoped user sees 0 out-of-scope jobs/alerts/clients). - Two-factor (TOTP) added for user logins; secrets encrypted at rest.
Relay auth (hardened):
- Relay auth (
/ws/remote): each native session has separate, one-time viewer and agent tickets, stored only as hashes. Viewer tickets are supplied only in the WebSocket handshake rather than the URL; the viewer additionally presents its authenticated session identity, which must match the principal that created the remote session. An agent-only URL-ticket bridge is opt-in throughRMM_ALLOW_LEGACY_AGENT_RELAYfor upgrades and is disabled by default. Both ticket roles are invalidated by expiry or device revocation.
Remaining (tracked):
- TLS and source ACLs must be enforced by the trusted edge in production (see the deploy runbook).
- Script sandboxing remains a roadmap item; privileged jobs and the terminal intentionally run with the system-service account today.
- Native relay media is not end-to-end encrypted; the relay and TLS edge remain trust boundaries.
- Release Ed25519 signatures are not Authenticode or Apple notarization.
