Security Model

August 29, 2026 · View on GitHub

This document describes the authentication, authorization, and certificate management model of the RBAC proxy.

Threat model

The proxy protects against:

  • Unauthorized Docker API access: only users with valid client certificates can reach the Docker daemon through the external listener.
  • Accidental infrastructure mutation: non-admin users and even admins are blocked from creating or deleting resources in the protected Swarm stack. See configuration.md for the full permission matrix.
  • Privilege escalation via exec/attach: non-admin users cannot exec or attach into protected stack containers via the Docker API, and cannot use the agent exec endpoint (/v1/exec), preventing access to admin tools (e.g. swcproxy CLI with direct database access).

The proxy does not protect against:

  • Compromised client certificates (no revocation mechanism — delete the user instead).
  • Attacks on the Docker daemon itself (the proxy is a policy layer, not a sandbox).
  • Network-level attacks between proxy and daemon (use backend TLS if the daemon is remote).
  • Compromised containers on the internal overlay network (see overlay network trust below).

Authentication layers

mTLS client certificates

The primary authentication mechanism. When PROXY_TLS_CLIENT_CA is set, the external listener requires a valid client certificate signed by the configured CA. The proxy extracts the username from the certificate (SAN email if present, otherwise Subject CN) and looks it up in the user store. Disabled users are rejected with 403.

Bearer token (management API)

When PROXY_ADMIN_TOKEN is set, all /api/v1/* requests require Authorization: Bearer <token>. The token is compared using constant-time comparison. This protects user management operations; it does not authenticate Docker proxy requests.

Onboarding tokens

One-time tokens generated by swcproxy user add or SetOnboardToken. Each token is 256-bit cryptographically random (crypto/rand). Consuming a token via GET /api/v1/onboard/{token} marks it as used atomically — it cannot be reused. The onboard endpoint does not require mTLS, allowing new users to bootstrap their certificate.

Authorization

Authorization has two composing layers (deny-wins):

  1. RBAC — the primary layer. Every proxied request on the external listener is mapped to a {resource, verb} and authorized against the caller's roles before it reaches the Docker daemon. Reads are authorized too.
    • Model (dynamic, additive, Kubernetes-style): a Role is a named set of {resources, verbs} rules (* wildcards allowed); a RoleBinding maps a user to a role; effective permission is the union of all bound roles. Default-deny — an unmapped path or a missing grant is a 403.
    • Built-in roles viewer (read-only), operator (deploy/update stacks & services, exec/forward to non-infra workloads), and admin (*). Seeded on startup and editable; custom roles/bindings are managed via /api/v1/roles and /api/v1/bindings (admin-token protected).
    • Stacks via label: a stack-labeled mutation is authorized under the stacks resource OR the concrete resource, so operator can deploy a full stack (incl. its secrets/configs/networks) without being able to create standalone infra resources or enumerate/read secrets. Reads never get the stacks-OR, so viewer's lack of secrets/volumes/configs read is not bypassable by labeling.
    • Last-admin lockout: the store refuses to delete the last admin binding or to edit a role in a way that would leave the cluster with no admin.
    • RBAC is enforced only when mTLS identifies callers; it is a no-op on the internal listener and when PROXY_TLS_CLIENT_CA is unset.
  2. Protected-stack guard — a narrower, additional layer that shields the proxy's own infrastructure stack regardless of role (no external mutation of the protected stack, no overlay-membership pivots, no forward into it). It keys off the legacy User.Role admin flag, a separate axis from RBAC.

The full role/verb matrix is in rbac.md, and the 21-row protected-stack matrix — including the rows that deny every external role, admin included — is in configuration.md.

Certificate lifecycle

  • Algorithm: ECDSA P-256 (both CA and client certs).
  • CA validity: 10 years (when auto-generated via GenerateCA).
  • Client cert validity: 1 year from issuance.
  • Private key handling: client private keys are generated in memory and returned in the API response. They are never stored on the server. If lost, the admin must delete the user and create a new one.
  • No revocation: there is no CRL or OCSP. To revoke access, delete the user from the store — the proxy rejects certificates whose CN/email does not match an enabled user.

Onboarding token security

  • 256-bit random value (crypto/rand, 32 bytes hex-encoded).
  • One-time use: consumed atomically in the store.
  • TTL expiry: tokens expire after a configurable lifetime (PROXY_ONBOARDING_TOKEN_TTL, default 24h). Consuming an expired token returns 410 Gone with "token expired". Expiry cannot be disabled — the TTL must be strictly positive.
  • The token is the sole credential for the onboard endpoint — no mTLS required, allowing bootstrapping.

Exec guard limitations

The ExecGuard middleware blocks exec/attach on protected-stack containers. Key constraints:

  • Fail-closed without mTLS: when PROXY_TLS_CLIENT_CA is not set, the guard blocks all exec/attach on the external listener because no user can prove admin status. Use the internal listener (PROXY_INTERNAL_LISTEN) for exec access without mTLS. Bootstrap always configures mTLS.
  • Identity is cert-based: user identity comes from the client certificate CN, not from any in-app user selection. To test exec restrictions with a non-admin user, that user must have their own client certificate and Docker context (obtained via the onboarding flow).

Port-forward guard

The same ExecGuard middleware also gates /v1/forward (raw-TCP port-forward via the agent-manager). The path is recognised by isAgentControlPath, which is a sibling of isExecPath covering both verbs.

The forward policy is stricter than exec:

  • Forward to a task on the protected stack is denied for every external role — including admin. Symmetric to the existing T2 connect/disconnect block: an admin-cert compromise must not yield an exfil channel through this very proxy. Legitimate admin-driven forwarding into infrastructure containers must happen via the host Docker socket or the internal listener.
  • Forward to any non-protected task is allowed for all authenticated users (same policy as exec).
  • dest_addr query parameter is rejected at the proxy edge with HTTP 400. The agent computes the forward destination from container_id itself; allowing a client-supplied IP would give a confused-deputy SSRF on the overlay. Defence-in-depth: agent-manager and agent both reject dest_addr too.

Audit denials use AuditGuardBlocked with a forward:<path> discriminator, matching the existing exec convention.

A per-user open-rate cap (default 16, hard 64; returning 429 Too Many Requests) is planned but not yet enforced — tracked as a follow-up to limit port-enumeration via repeated forward opens. Every successful open is already audited.

Overlay network trust

When deployed as a Docker Swarm stack, the rbac-proxy forwards /v1/* requests to the agent-manager, which in turn connects to per-node agents. Confidentiality and authentication for these internal hops are provided by application-layer mutual TLS on the rbac-proxy → agent-manager → agent chain, so swarmcli-agent-net can be a plain overlay (internal: true, no encrypted: "true").

Internal mTLS (#63)

The proxy presents a bootstrap-issued internal-client certificate and verifies the agent-manager's internal-server certificate; the agent-manager in turn authenticates to the agents the same way. A wss:///https:// PROXY_AGENT_MANAGER_URL enables this hop and fails closed if the TLS material is absent. The material is delivered as the consolidated internal-client Docker secret — a single PEM holding the internal-client cert, its key, and the internal CA cert — via PROXY_AGENT_MANAGER_TLS_BUNDLE (or, as a fallback for older bootstraps, the PROXY_AGENT_MANAGER_TLS_CERT/_KEY/_CA trio). ServerName is pinned to the fixed, stack-name-independent SAN swarmcli-agent-manager that the internal-server cert carries, not the stack-qualified dial host.

This internal mTLS chain has its own dedicated CA, deliberately separate from the user-authentication CA that issues client certs. The two trust roots are isolated — neither key is mounted where the other is — and because Docker secrets are write-only, an in-place upgrade cannot read the existing user CA key back to mint internal certs under it. The internal CA key is used only to sign the two internal leaves and is then discarded (it is never persisted); rotation is a re-bootstrap.

:bootstrap --migrate is the non-destructive path that moves an already-onboarded stack onto this model: it recreates the agent-net overlay as a plain overlay and regenerates the internal mTLS certs from the dedicated internal CA, while preserving the user CA, the issued user certs, the admin token, the managed context, and the RBAC database — no user re-onboarding. (A Docker overlay's encrypted opt cannot be changed in place and docker stack deploy never reconciles it, so the old overlay must be torn down and recreated.)

Why the overlay moved off encrypted: "true" (rationale)

The old swarmcli-agent-net overlay carried encrypted: "true", which tunnels overlay confidentiality over IPsec ESP (IP protocol 50) between every node pair. Clusters that block ESP to a worker silently drop the agent-manager → worker-agent hop, breaking worker-node shells and port-forwards. Moving confidentiality up to application-layer mTLS removes the ESP requirement (so those hops work on any cluster) and additionally authenticates the peers — defence the IPsec overlay never offered: a rogue workload that reaches agent-net still cannot invoke the agent or agent-manager without a CA-signed internal-client cert.

Overlay-membership pivot is still blocked (T1/T2)

Overlay-membership mutations through the external proxy listener remain blocked for every role, including adminPOST /services/create with TaskTemplate.Networks targeting the protected overlay, POST /services/{id}/update pulling a non-protected service onto it, and POST /networks/{id}/{connect,disconnect} against the protected overlay all return 403. An admin-cert compromise therefore cannot bootstrap a pivot onto swarmcli-agent-net. Legitimate sysadmin overlay work (attaching troubleshooting sidecars, joining containers to agent-net for diagnostics) must happen via the host Docker socket on a manager node or via the internal loopback listener (PROXY_INTERNAL_LISTEN). Admin docker exec / attach into containers that are already on the overlay continues to work through the proxy — the guard scope is membership, not traffic.

Residual risk: internal mTLS authenticates the three first-party services to each other, but each still mounts /var/run/docker.sock:ro and holds a valid internal-client identity, so a vulnerability in one service that yields code execution gives the attacker that service's lateral reach. This is mitigated by: read-only Docker socket mounts, distroless base images (agent, agent-manager), image vulnerability scanning, and restricting who can deploy to the swarm.

Stack-qualified agent-manager URL (T5 mitigation)

Because the rbac-proxy resolves PROXY_AGENT_MANAGER_URL via Docker overlay DNS, a bare single-label host (e.g. tcp://agent-manager:8080) is vulnerable to name-collision MITM: if an attacker ever reaches the overlay and registers a cross-stack service also named agent-manager, Docker's round-robin DNS would deliver half of the admin exec traffic to the attacker's endpoint, enabling token/session theft. See swarmcli-agent/docs/threat-model.md §T5.

Always set PROXY_AGENT_MANAGER_URL to the stack-qualified form, for example tcp://swarmctl_agent-manager:8080 (where swarmctl is the protected stack name). The proxy emits a startup warning when the host portion is an unqualified short name.

Operational recommendations

  • Bind the internal listener to loopback (127.0.0.1:2375) or access it only via docker exec. Never expose it to the network.
  • Set a strong PROXY_ADMIN_TOKEN and rotate it periodically.
  • Use backend TLS (PROXY_DOCKER_TLS_*) when the Docker daemon is on a remote host.
  • Review the audit log regularly with swcproxy audit ls. All business actions (user CRUD, certificate issuance, guard blocks, onboarding) are persisted to the audit_log table in the same database as user records. Each entry records actor, action, resource, status (success/denied), detail, and source IP.
  • Monitor logs for guard: blocked entries (attempted policy violations) and unauthorized entries (authentication failures). Real-time auth events (mTLS success/failure) are logged via zap to stdout; persisted audit entries cover business actions only.
  • Back up the user store (SQLite file or PostgreSQL database) — it contains both user records and the audit log.