Skip to content

Authentication Flows

Proxbox spans five distinct authentication boundaries. This page documents each one, from MCP/REST and browser principals through to the Proxmox VE API.


Full Authentication Chain

sequenceDiagram
    autonumber
    participant Browser
    participant NB as NetBox<br/>(Django session + RBAC)
    participant Plugin as netbox-proxbox<br/>services layer
    participant API as proxbox-api<br/>(X-Proxbox-API-Key)
    participant NBAPI as NetBox REST API<br/>(Token v1/v2)
    participant PVE as Proxmox VE<br/>(API Token / Ticket)

    Browser->>NB: Session cookie (CSRF-protected forms)
    NB->>NB: ObjectPermissionRequiredMixin\nContentTypePermissionRequiredMixin
    NB->>Plugin: Authorized Django request
    Plugin->>API: HTTP GET + X-Proxbox-API-Key header
    API->>API: APIKeyAuthMiddleware: bcrypt verify key
    API->>NBAPI: aiohttp + Authorization: Token <secret>
    NBAPI-->>API: NetBox objects (JSON)
    API->>PVE: aiohttp + PVEAPIToken=user@realm!id=secret
    PVE-->>API: Proxmox resources (JSON)

Boundary 0: MCP client ↔ netbox-sdk ↔ NetBox REST API

The semantic MCP bridge will reuse the NetBox principal configured in an exact compatible netbox-sdk. The plugin contains a bridge-v1 producer manifest, but no released SDK identity is activated yet; the API root omits mcp and the direct manifest route returns 503. Once the checked activation artifact names an immutable SDK that passes paired CI, that SDK validates the manifest, confines declared paths below the plugin API root, and exposes generic plugin_list_tools / plugin_call_tool MCP tools that send ordinary authenticated REST requests. Proxbox does not receive or store a token in the manifest or tool arguments and does not create an MCP-specific credential.

The descriptor follows NetBox's LOGIN_REQUIRED policy. The target sync/schedule/ DRF view independently requires core.add_job for both the advertised list_sync_jobs and schedule_sync operations. A compatible SDK's disabled-by-default mutation gate protects against accidental scheduling, but it is one global switch for all MCP mutations, not per-plugin authorization; the DRF permission remains authoritative.

See Semantic MCP Bridge for the complete discovery sequence, strict schemas, executable examples, error handling, and agent safety contract.


Boundary 1: Browser ↔ NetBox Plugin

The browser interacts with the plugin through standard NetBox Django views. All authentication is handled by NetBox's Django session framework.

Permission Model

View category Mixin Required permission
Endpoint CRUD (ProxmoxEndpoint, NetBoxEndpoint, FastAPIEndpoint) ObjectPermissionRequiredMixin (NetBox generic) view, add, change, delete on the model
Sync action views ContentTypePermissionRequiredMixin add on core.Job (for enqueue), delete on core.Job (for cancel)
WebSocket test / status pages TokenConditionalLoginRequiredMixin view on FastAPIEndpoint
Dashboard / JSON endpoints ConditionalLoginRequiredMixin At least view on ProxmoxEndpoint or NetBoxEndpoint
Plugin REST API NetBoxModelViewSet (standard DRF) Standard NetBox API token required
netbox_proxbox/views/proxbox_access.py (pattern)
class SyncNowView(ContentTypePermissionRequiredMixin, View):
    def get_required_permission(self):
        return "core.add_job"   # operator must have permission to queue jobs

Boundary 2: NetBox Plugin ↔ proxbox-api

The plugin authenticates to proxbox-api using a bcrypt-hashed API key sent in the X-Proxbox-API-Key HTTP header.

API Key Adoption and Bootstrap

FastAPIEndpoint.save() is the credential persistence boundary. UI, import, REST, and direct-model writes converge there. An explicit candidate still runs the synchronous adoption gate. A save without one persists a pending blank fingerprint and encrypted candidate, then schedules services.endpoint_autoconfiguration with transaction.on_commit; deferred startup retries pending legacy rows. This ordering retains a generated key before the backend can accept it, so an outer rollback cannot lose its only copy.

Automatic discovery has an explicit allowlist. For an existing row it may probe only the exact URL/IP, port, and TLS policy persisted through endpoint configuration. With no row it may probe only PLUGINS_CONFIG candidates and same-site names derived from NetBox's trusted public origin. It performs no host/subnet scan and follows no redirect. A stored key is replayed only to the configured target. A generated key is retained locally and used only after the backend reports a consistent empty-key state.

Successful adoption persists a credential-free SHA-256 backend_key_target_fingerprint alongside the ciphertext. The fingerprint binds the candidate to the canonical primary HTTP authority, fallback IP, port, HTTP/TLS flags, and WebSocket authority flags. Every runtime HTTP and WebSocket credential lookup recomputes it, using a fresh IP foreign-key value, and refuses to return the key if any bound value drifted. Legacy rows created before migration 0075_fastapi_backend_key_target_fingerprint remain blocked until automatic discovery authenticates their stored key, or an operator uses the manual proxbox_fix_tokens --fix recovery path.

Before any bootstrap-status request, the adoption service validates and canonicalizes the target authority. DNS names follow the model hostname contract, IP literals are parsed, IPv6 is bracketed, and URL userinfo, paths, queries, fragments, malformed ports, and authority-injection strings are rejected without network traffic. Redirect rejection is therefore defense in depth rather than the first authority boundary.

sequenceDiagram
    participant Operator
    participant NB as NetBox Plugin
    participant API as proxbox-api /auth/

    Operator->>NB: Save enabled endpoint (token optional)
    NB->>NB: Persist pending fingerprint when no candidate was supplied
    NB->>NB: Resolve exact configured target from discovery allowlist
    NB->>API: GET /auth/bootstrap-status (redirects disabled)
    alt no backend keys
        API-->>NB: needs_bootstrap=true, has_db_keys=false
        NB->>NB: Generate and retain a strong local candidate
        NB->>API: POST /auth/register-key once (redirects disabled)
        API->>API: bcrypt.hash(candidate) → store in ApiKey table
        API-->>NB: 201 Created
    else initialized backend
        API-->>NB: needs_bootstrap=false, has_db_keys=true
        NB->>API: GET /auth/keys with candidate header (redirects disabled)
        API-->>NB: 200 + valid key-list schema
    end
    NB->>NB: Encrypt and persist the same candidate

First key only

POST /auth/register-key is exempt from authentication but accepts only the first API key. It is used only after a consistent empty-state response and only for a candidate already retained locally. HTTP 409 is failure, never proof of adoption. Subsequent key management requires authentication via POST /auth/keys and DELETE /auth/keys/{id}.

Disabled means no connection

A new disabled FastAPI endpoint stays keyless. Disabled rows are skipped by signals, jobs, status checks, WebSocket/storage views, and proxbox_fix_tokens, including --fix. Enabling or moving a row can enter pending auto-configuration without a pasted key. Explicit candidate submission remains required for manual key rotation. Rejections and transport failures preserve prior ciphertext and keep runtime access blocked.

The WebSocket bridge applies the same durable trust check before opening a connection, again after the handshake, periodically while a busy stream is running, and before queued messages are sent. It disables ambient proxy use and refuses a server-selected redirect before adding the API-key header. Saving the endpoint cancels the old client so a stale task cannot continue with the prior target or key.

The complete allowlist/state-machine contract and its automated evidence are maintained in Endpoint Auto-Configuration.

APIKeyAuthMiddleware

Every request to proxbox-api (except bootstrap routes) passes through APIKeyAuthMiddleware:

  1. Extract X-Proxbox-API-Key from the request headers
  2. Check if the client IP is locked out (AuthLockout table)
  3. Verify the key against all stored bcrypt hashes via ApiKey.verify_any_async()
  4. On failure: increment attempt counter; lock out IP after 5 failed attempts for 300 seconds
  5. On success: clear the failure counter and proceed
proxbox_api/auth.py
_LOCKOUT_DURATION = 300   # seconds
_MAX_FAILED_ATTEMPTS = 5

async def check_auth_header_with_session_async(session, api_key, client_ip):
    if await is_locked_out_async(session, client_ip):
        return False, "Too many failed authentication attempts."
    if not await ApiKey.verify_any_async(session, api_key):
        await record_failed_attempt_async(session, client_ip)
        ...
    await clear_failed_attempts_async(session, client_ip)
    return True, None

Boundary 3: proxbox-api ↔ NetBox REST API

proxbox-api accesses NetBox via the netbox-sdk api() facade. It supports both NetBox token formats:

Authorization: Token <token_secret>
A single opaque token stored in the NetBoxEndpoint.token_secret field (encrypted at rest in NetBox).

Authorization: Bearer nbt_<key>.<secret>
Split into token_key and token_secret. The nbt_ prefix identifies v2 format. The netbox-sdk handles encoding automatically.

proxbox_api/session/netbox.py
def netbox_config_from_endpoint(endpoint: NetBoxEndpoint) -> Config:
    tv = (endpoint.token_version or "v1").lower()   # "v1" or "v2"
    return Config(
        base_url=endpoint.url,
        token_version=tv,
        token_key=key,          # None for v1; key part of nbt_ token for v2
        token_secret=decrypted_token,
        timeout=_resolve_netbox_timeout(),
        ssl_verify=endpoint.verify_ssl,
    )

The token is decrypted from the SQLite NetBoxEndpoint model using get_decrypted_token() — proxbox-api stores it encrypted at rest using the cryptography package.


Boundary 4: proxbox-api ↔ Proxmox VE

PVEAPIToken=user@realm!tokenid=<uuid-secret>
Token is stored in the ProxmoxEndpoint table in proxbox-api's SQLite database. The proxmox-openapi SDK reads the token at session creation and includes it in the Authorization header for every request. No renewal needed.

proxbox-api session/proxmox_core.py (simplified)
sdk = ProxmoxSDK(
    host=endpoint.host,
    port=endpoint.port,
    token=endpoint.token,       # "PVEAPIToken=root@pam!mytoken=<uuid>"
    verify_ssl=endpoint.verify_ssl,
)

sdk = ProxmoxSDK(
    host=endpoint.host,
    username="root@pam",
    password="secret",
    # TOTP optional: otp="123456"
)
The SDK performs a POST to /api2/json/access/ticket to obtain a short-lived PVEAuthCookie + CSRFPreventionToken pair. The ticket is valid for 2 hours and is renewed automatically. Credentials are redacted from all log output by SensitiveDataFilter.

SSL verification is controlled per endpoint via verify_ssl. When verify_ssl=False the same SSL context is applied to both the auth request and all subsequent API calls.


Django Signal Responsibilities

signals.py never adopts or persists a FastAPI credential. Its FastAPIEndpoint receiver only confirms that the model gate completed. The NetBoxEndpoint and ProxmoxEndpoint receivers resolve an enabled FastAPIEndpoint, authenticate its stored key through the read-only adoption check, and then push those downstream endpoint records to proxbox-api. The FastAPIEndpoint receiver itself performs no request and repeated receiver invocation cannot generate, bootstrap, or rewrite a key.

This separation matters because signal exceptions can roll back the database transaction after a remote bootstrap has succeeded. The operator-retained candidate makes that state recoverable: retry the save with the same key, which the initialized backend can authenticate. The proxbox_fix_tokens command is an explicit legacy-repair path; without --fix it is read-only, and --fix records a reviewed legacy target after authenticating its durably stored key; it bootstraps that same key only when the backend reports no keys. A blank legacy fingerprint is never probed without --fix, and a nonblank fingerprint that no longer matches is refused without network traffic.