uptrakit

Authentication and Authorization

Authentication and Authorization

MethodScopeDetails
Password (Argon2id)User loginLocal accounts with hashed passwords.
OIDCUser loginExternal identity providers with auto-create or account linking. Requires the oidc Cargo feature (enabled by default).
Device authorizationCLI loginRFC 8628-style flow: device code, browser approval, API token issuance. Status tracked via DeviceAuthStatus enum (pending, authorized, expired).
JWT access tokensAPI requestsShort-lived tokens that carry resolved permissions (never stored).
Refresh tokensAPI requestsSHA-256 hashed, 7-day expiry, rotated on each use within a DB transaction, revoking the predecessor. Session integrity validated on every use (see below).
API tokensProgrammatic accessLong-lived, revocable bearer tokens stored in the database.
mTLS client certsAgent/MQTT connectionsIssued after CSR approval and validated per connection.
Forwarded cert headersReverse proxyTrusted proxies forward cert info/PEM; issuer CN verified.
Enrollment tokensService onboardingMultiple named tokens stored in the enrollment_tokens table (Argon2id hashed). Each token supports capability scoping, usage limits, and TTL. See Enrollment Tokens API.
System enrollment tokensSystem service onboardingGlobal (non-tenant) named tokens stored in the system_enrollment_tokens table (Argon2id hashed). Supports usage limits and TTL. See System Enrollment Tokens API.

JWT Access Token Claims Contract

Every access token minted by JwtManager::create_access_token includes:

ClaimValuePurpose
iss"uptrakit"Identifies the issuing deployment.
aud["uptrakit"]Restricts token acceptance to Uptrakit instances.
subUser UUIDIdentifies the subject user.
expUnix timestampToken expiry (15 minutes from issuance).
jtiUUIDPer-token unique identifier used for denylist lookups.
permissionsstring[]Resolved permissions embedded at issuance time.
auth_method"password" | "oidc" | "api_token"How the user authenticated.

decode_access_token validates all three of exp, iss, and aud. Tokens that lack any of these claims, or that carry the wrong values (e.g. tokens issued by a different deployment sharing the same signing key), are rejected with AuthError::JwtDecode. This prevents cross-deployment token replay attacks in scenarios where the signing key file is accidentally shared or restored from backup.

JWT Signing Key Storage

The JWT signing key is stored in the settings table under auth.jwt_signing_key (global scope, base64 encoded) and is encrypted at rest using AES-256-GCM via encrypt_str() — the same algorithm used for all other sensitive fields (EncryptedString). On every read the value is decrypted transparently before use.

The JWT key uses the context-bound ENC:v2: format with AAD "uptrakit:settings:jwt_signing_key", preventing this ciphertext from being reused in any other encrypted column even if the master key is compromised and an attacker attempts a relocation attack.

Legacy unencrypted keys (base64 only, written before encryption was introduced) are transparently re-encrypted on the next read using ENC:v2:. Legacy ENC:v1: keys are accepted during decryption for backward compatibility with existing installations. No operator intervention is required.

See Secrets Handling and Encryption for the encryption format and master key requirements.

Session Integrity Validation

Refresh token verification and rotation validate session data integrity before proceeding:

  • OIDC sessions must have a valid oidc_provider_id. If the provider ID is missing or corrupt (e.g., auth_method = 'oidc' but oidc_provider_id IS NULL), the session is rejected with AuthError::InvalidSession and a warning is logged. The session is never silently downgraded to password authentication.
  • JWT access tokens with auth_method = "oidc" are similarly rejected if oidc_provider_id is missing or unparseable, returning HTTP 401 instead of falling back to a different auth method.
  • Database constraint: The sessions table enforces CHECK(auth_method != 'oidc' OR oidc_provider_id IS NOT NULL) to prevent invalid state at the storage layer.

See also: Secrets Handling and Encryption for encryption-at-rest details.

OIDC Email Verification Enforcement

resolve_oidc_user checks the email_verified claim from the OIDC ID token before performing any database lookup. This prevents account creation or matching for addresses that the identity provider has not confirmed.

email_verified claimBehavior
trueAccepted — proceeds to user resolution
falseRejected — returns OidcUserResolution::EmailNotVerified; user is redirected to the login page with error=email_not_verified
absent / nullRejected — treated the same as false. A rogue IdP that omits the claim cannot bypass verification. Providers that omit the claim for confirmed accounts must be configured to always include email_verified: true.

The check occurs at the entry point of resolve_oidc_user before any DB query, ensuring no account is created or linked for an unverified email regardless of auto_create or role-mapping configuration.

See also: docs/development/coding-standards.md for the security guard placement convention.

Database Error Propagation in Auth Handlers

Database errors in authentication and authorization handlers must always propagate as HTTP 500 Internal Server Error. Silently defaulting on DB failure is a security defect, not a graceful fallback.

Why defaults are dangerous

SiteAnti-patternEffect
require_auth.rs — load user permissions.unwrap_or_default()DB outage → empty permission set → 403 Forbidden; legitimate requests blocked silently
oidc_auth.rs — count existing users.unwrap_or(false)DB outage → assume zero users → unintended first-admin OIDC registration allowed
oidc_auth.rs — list OIDC providers.unwrap_or_default()DB outage → empty provider list → correct behavior obscured, outage masked

Required pattern

All auth-path DB queries must be propagated with ? after mapping to an appropriate error:

// ✓ Correct — DB outage surfaces as 500; access never silently granted or denied
let permissions = get_user_permissions(db, user_id)
    .await
    .map_err(|e| {
        tracing::error!(err = %e, user_id = %user_id, "failed to load user permissions");
        AuthFailure::InternalError
    })?;

See Error Handling — Pattern 20 for the full pattern with examples.

Risk level: Low

When OIDC account linking is required the backend redirects to /login?link_required=true&email=<email>#link_token=<token>. The sensitive link_token is placed in the URL fragment instead of the query string, so it is not sent to the server and does not appear in controller access logs or Referer headers.

Mitigations in place

  1. Single-use, short-lived: the token is consumed atomically from pending_account_links on first use and expires server-side within a short window.
  2. Fragment transport: the browser keeps the token client-side in the fragment, removing it from server-side request logs and request targets.
  3. Same-origin redirect: the redirect is to the same origin (/login), so there is no cross-origin referrer leakage.
  4. Referrer-Policy: no-referrer is set on the redirect response in crates/ui/web-api/src/routes/oidc_auth.rs so the token URL is not forwarded in Referer headers when the browser subsequently loads third-party resources.
  5. User already authenticated: the token only exists after the user has successfully completed OIDC authentication; it does not grant initial access.

Residual risk

The token still appears in the browser address bar and browser history until the user leaves or rewrites the page URL. Fragment transport removes server-side logging exposure, but a compromised or observed client can still capture the token during its validity window.

Permissions Model - Detailed

Authorization uses a typed Permission enum (defined in crates/shared/types/src/permissions.rs, re-exported via crates/shared/web-api-types/src/permissions.rs) rather than raw role-name strings. There are 33 granular permissions organized by domain:

Permissions reference

Services

PermissionSerialized namePurpose
ViewServicesview_servicesView tenant services and their status
ApproveServicesapprove_servicesApprove pending service enrollments
RejectServicesreject_servicesReject pending service enrollments
RemoveServicesremove_servicesDeactivate/remove services
UpdateServicesupdate_servicesUpdate service settings (ping interval, freeze, merge)

System services

PermissionSerialized namePurpose
ViewSystemServicesview_system_servicesView system services (MQTT bridge, external scheduler)
ApproveSystemServicesapprove_system_servicesApprove pending system services
RejectSystemServicesreject_system_servicesReject pending system services
RemoveSystemServicesremove_system_servicesDeactivate system services
UpdateSystemServicesupdate_system_servicesUpdate system service settings

Software

PermissionSerialized namePurpose
ViewSoftwareview_softwareView software items, plugin configs, history
CreateSoftwarecreate_softwareCreate software items and plugin configs
UpdateSoftwareupdate_softwareEdit software items and plugin configs
DeleteSoftwaredelete_softwareDelete software items and plugin configs
TriggerCheckstrigger_checksTrigger version checks and autodiscovery
TriggerUpdatestrigger_updatesTrigger update execution (single and batch)
ManageSchedulermanage_schedulerManage scheduled tasks

Hosts

PermissionSerialized namePurpose
ViewHostsview_hostsView hosts
UpdateHostsupdate_hostsUpdate host properties and tags
DeactivateHostsdeactivate_hostsDeactivate hosts

Settings

PermissionSerialized namePurpose
ViewSettingsview_settingsView all tenant settings (unified read)
ManageAuthSettingsmanage_auth_settingsManage registration, authentication, OIDC providers
ManageEnrollmentTokensmanage_enrollment_tokensManage tenant enrollment tokens
ManageAgentCertsmanage_agent_certsManage agent certificate settings
ManageGlobalSettingsmanage_global_settingsManage global infrastructure settings

Commands

PermissionSerialized namePurpose
ManageCommandsmanage_commandsModify command-bearing plugin config fields (code execution authority)
TestPluginConfigstest_plugin_configsTest plugin configurations against hosts (dry-run validation)

ManageCommands grants effective code-execution authority on all managed hosts assigned to the affected software items. Users with this permission can configure arbitrary shell commands that execute on managed hosts. Assign with the same care as granting root access.

Notifications

PermissionSerialized namePurpose
ViewNotificationsview_notificationsView notification channels, rules, log
ManageNotificationsmanage_notificationsCreate/modify notification channels and rules; SMTP settings

Audit logs

PermissionSerialized namePurpose
ViewAuditLogsview_audit_logsView tenant-scoped audit log entries
ViewSystemAuditLogsview_system_audit_logsView system-level audit log entries

User management

PermissionSerialized namePurpose
ManageUsersmanage_usersManage user roles and access

Autodiscovery

PermissionSerialized namePurpose
ManageIgnoresmanage_ignoresManage autodiscovery ignore rules

Built-in roles

Eight built-in roles group permissions into logical responsibilities:

RolePermissions
viewerview_services, view_software, view_hosts, view_settings
operatorapprove_services, reject_services, trigger_checks, trigger_updates
service_managerapprove_services, reject_services, remove_services, update_services
software_managercreate_software, update_software, delete_software, trigger_checks, trigger_updates, manage_scheduler, manage_ignores, test_plugin_configs
host_managerupdate_hosts, deactivate_hosts
settings_managermanage_auth_settings, manage_enrollment_tokens, manage_agent_certs, view_notifications, manage_notifications, view_audit_logs, manage_users
command_managermanage_commands, test_plugin_configs
system_administratormanage_global_settings, view_system_services, approve_system_services, reject_system_services, remove_system_services, update_system_services, view_system_audit_logs

Built-in roles are marked with is_built_in = true in the roles table.

Access presets

Access presets are code-defined bundles (not stored in the database) that assign one or more roles in a single operation. They are exposed via the GET /api/v1/access-presets and POST /api/v1/users/{id}/apply-preset endpoints.

PresetRoles assignedUse case
read_onlyviewerDashboard viewers, stakeholders
operatorviewer, operatorOn-call staff
managerviewer, service_manager, software_manager, host_managerTeam leads
administratorviewer, service_manager, software_manager, host_manager, settings_manager, command_managerTenant administrators
ownerAll 8 rolesSystem owner

See User Management API for the full endpoint reference and User Management Guide for the end-user documentation.

First user setup

The first registered user -- whether via password or OIDC -- receives all 8 built-in roles (equivalent to the owner preset). Subsequent users receive only the viewer role by default. OIDC role mapping can override this.

Lockout prevention

The system prevents removing the manage_users permission from the last user who holds it. Attempts to change roles in a way that would leave no user with manage_users are rejected with HTTP 409 Conflict.

How it works

  1. get_user_permissions() (middleware/require_auth.rs) resolves a user's permissions: user -> user_roles -> role_permissions -> permissions table.
  2. The resolved Vec<Permission> is embedded in the JWT access token (permissions claim) and returned in UserResponse.permissions.
  3. The require_auth middleware injects AuthenticatedUser with the permissions field decoded from the JWT.
  4. Route handlers declare their permission requirement via a typed Axum extractor (e.g. CanViewHosts(_user): CanViewHosts). The extractor is defined in crates/ui/web-api/src/middleware/permission.rs using a macro that generates one concrete struct per permission. If the user lacks the permission the extractor short-circuits with 403 Forbidden before the handler body runs. No DB round-trip is needed.
  5. Every protected endpoint also carries an x-required-permission OpenAPI extension (set in the #[utoipa::path] annotation, e.g. extensions(("x-required-permission" = json!("view_hosts")))). This makes the required permission machine-readable in the generated OpenAPI spec.
  6. The frontend receives permissions as string[] (e.g. ["view_settings", "view_services"]) and uses the Permission TypeScript enum for checks.

Self-authenticated endpoints and the "self" sentinel

Some endpoints -- create_api_token, list_api_tokens, revoke_api_token, logout, and me -- are authenticated (require a valid Bearer token) but not governed by the RBAC permission model. Any authenticated user may call them regardless of their assigned roles. These endpoints use Extension<AuthenticatedUser> directly rather than a typed permission extractor, and carry:

extensions(("x-required-permission" = json!("self")))

The sentinel value "self" is distinct from the named Permission variants. It signals to automated permission-audit tooling that the endpoint requires only authentication (a valid token), not any specific RBAC permission. Tools must treat "self" as "any authenticated user is authorized".

Permission extractor reference

ExtractorPermission checked
CanViewSettingsPermission::ViewSettings
CanManageAuthSettingsPermission::ManageAuthSettings
CanManageEnrollmentTokensPermission::ManageEnrollmentTokens
CanManageAgentCertsPermission::ManageAgentCerts
CanManageGlobalSettingsPermission::ManageGlobalSettings
CanViewServicesPermission::ViewServices
CanApproveServicesPermission::ApproveServices
CanRejectServicesPermission::RejectServices
CanRemoveServicesPermission::RemoveServices
CanUpdateServicesPermission::UpdateServices
CanViewSoftwarePermission::ViewSoftware
CanCreateSoftwarePermission::CreateSoftware
CanUpdateSoftwarePermission::UpdateSoftware
CanDeleteSoftwarePermission::DeleteSoftware
CanTriggerChecksPermission::TriggerChecks
CanTriggerUpdatesPermission::TriggerUpdates
CanManageSchedulerPermission::ManageScheduler
CanManageCommandsPermission::ManageCommands
CanTestPluginConfigsPermission::TestPluginConfigs
CanViewHostsPermission::ViewHosts
CanUpdateHostsPermission::UpdateHosts
CanDeactivateHostsPermission::DeactivateHosts
CanViewNotificationsPermission::ViewNotifications
CanManageNotificationsPermission::ManageNotifications
CanViewSystemServicesPermission::ViewSystemServices
CanApproveSystemServicesPermission::ApproveSystemServices
CanRejectSystemServicesPermission::RejectSystemServices
CanRemoveSystemServicesPermission::RemoveSystemServices
CanUpdateSystemServicesPermission::UpdateSystemServices
CanViewAuditLogsPermission::ViewAuditLogs
CanViewSystemAuditLogsPermission::ViewSystemAuditLogs
CanManageUsersPermission::ManageUsers
CanManageIgnoresPermission::ManageIgnores

All extractors derive Debug, expose pub AuthenticatedUser as field 0 for handler use, and provide a ::new(user) constructor for use in unit tests that call handlers directly (bypassing the HTTP layer).

See also: docs/development/coding-standards.md for the permission pattern conventions.

Adding a new permission

  1. Add a variant to the Permission enum in crates/shared/types/src/permissions.rs (with as_str / from_str / description arms).
  2. Write a DB migration to insert it into the permissions table and assign it to the appropriate built-in role(s) using grant_permission(). Decide which role(s) should include the new permission based on the domain (e.g. software permissions go to software_manager, host permissions to host_manager).
  3. Add a CanXxx => Permission::Xxx entry to the permission_extractor! macro call in crates/ui/web-api/src/middleware/permission.rs.
  4. Use CanXxx(_user): CanXxx (or CanXxx(user): CanXxx if you need the user) in the relevant route handler(s), and add extensions(("x-required-permission" = json!("xxx"))) to the corresponding #[utoipa::path] annotation.
  5. Add the variant to the Permission TypeScript enum in frontend/src/lib/types.ts.

System Service Credential Guard

Four capabilities grant access to sensitive infrastructure secrets:

CapabilitySecret delivered
database_accessDatabase connection URL
nats_accessNATS server URL
master_key_accessMaster AES-256 encryption key (hex)
ca_managementPermission to request CA rotation

These credentials are delivered via ServiceCredentials after mTLS authentication and are never published to NATS. Because they provide privileged access to the entire infrastructure, a service must declare the system_service capability to request any of them.

The guard runs at enrollment time, before any database write, in do_enroll():

if requests_system_creds && !has_system_service {
    bail!(AgentRouteError::Forbidden(
        "system credentials (database_access, nats_access, master_key_access, \
         ca_management) require the system_service capability"
    ));
}

A service that includes any of the four credential capabilities without system_service receives an ErrorCode::EnrollmentFailed response with a descriptive message. The guard does not apply to the system enrollment path (do_enroll_system_service), which is only reached when system_service is already present.

See System Services Architecture for the full enrollment flow and two-tier service model.

WebSocket Enrollment Secret Lookup

Services connect to the controller WebSocket endpoint at /api/v1/ws/service. Three authentication paths exist:

PathWhenHow
mTLSPost-enrollmentClient certificate issued after CSR approval; identity is cryptographically tied to the certificate serial number and service ID.
Bearer tokenEnrollment windowAuthorization: Bearer <enrollment_secret> header; secret is SHA-256 hashed and compared against the database.
AnonymousPre-enrollmentNo credentials; only enroll messages are accepted.

Bearer token lookup and the service_id query parameter

During the enrollment window (between save_enrollment() completing and the CA issuing and delivering the certificate), services authenticate via their enrollment secret. The controller resolves the service by querying:

SELECT * FROM services
WHERE enrollment_secret_hash = $1
  AND deactivated_at IS NULL
  -- optionally:
  AND id = $2

As a defence-in-depth measure, the service appends its known service_id as a URL query parameter:

wss://controller:3000/api/v1/ws/service?service_id=<uuid>

When service_id is present, the DB query is narrowed to that specific service. If the secret hash matches a different service's row (a practically impossible collision for 256-bit random secrets, but architecturally undesirable), the controller returns InvalidSecret — the same error as no match — so the caller cannot tell whether a collision occurred.

The service_id filter is enforced by the service-sdk. connect_ws() in crates/shared/service-sdk/src/ws.rs appends ?service_id=<uuid> to the WebSocket URL whenever the local identity file contains a service ID. During the first enrollment (no identity yet) the parameter is omitted and the lookup falls back to hash-only matching.

mTLS connections do not use the service_id parameter. Their identity is embedded in the client certificate; no bearer secret lookup is performed.

See also: Wire Protocol for connection sequencing.

OAuth 2.1 for MCP

Uptrakit ships a dual-auth model for MCP access: opaque upk_* API tokens for non-interactive callers (CLI, CI) and OAuth 2.1 for browser-capable MCP clients (Claude Desktop, Cursor). Auth is prefix-dispatched at the MCP Resource Server — a Bearer upk_ prefix routes to opaque token validation; a Bearer eyJ prefix routes to JWT validation.

The cross-rejection guarantee is enforced by audience claims. Dashboard JWTs (aud: ["uptrakit"], short-lived session tokens) are rejected by the MCP Resource Server's OAuth validator. OAuth JWTs (aud: ["<oauth.canonical_host>/mcp"]) are rejected by the Dashboard JWT middleware — aud mismatch by design, preventing session token reuse as OAuth bearer tokens and vice versa.

See also:

Content Security Policy

The admin UI's Content Security Policy (set in frontend/src/app.html) includes img-src 'self' https:. This allows images from any HTTPS domain, which is required to load OIDC provider logos configured by administrators.

Accepted risk: The admin who configures the OIDC provider logo URL is a trusted user. Logo URLs are validated as HTTPS-only via isValidLogoUrl() in frontend/src/lib/utils.ts before display. referrerpolicy="no-referrer" is applied to logo <img> elements, preventing the Uptrakit URL from leaking to logo hosts via the Referer header.