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 |
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:
- Extract
X-Proxbox-API-Keyfrom the request headers - Check if the client IP is locked out (
AuthLockouttable) - Verify the key against all stored bcrypt hashes via
ApiKey.verify_any_async() - On failure: increment attempt counter; lock out IP after 5 failed attempts for 300 seconds
- On success: clear the failure counter and proceed
_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>
NetBoxEndpoint.token_secret field (encrypted at rest in NetBox).
Authorization: Bearer nbt_<key>.<secret>
token_key and token_secret. The nbt_ prefix identifies v2 format. The netbox-sdk handles encoding automatically.
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>
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.
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"
)
/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.