Zero-Trust broker
A single control plane mediates every request. Nothing is trusted by network location — identity, device and context are checked on each connection, and the default is deny.
Pramaan ID is one on-premises control plane for sign-in, private-app access, web security and device trust — no data leaves your network. Every capability here comes with what it does, the exact switch to turn it on, and where the desktop and mobile apps fit. Proof, not trust.
One broker service mediates every request. Endpoints connect to it; it reaches your private apps only through outbound, per-site connectors — so app networks expose no inbound ports and nothing is trusted by location. Everything runs inside your perimeter.
Every request walks the same five stages — the diagram describes each one. The pulse traces the path a single sign-in takes from the endpoint to the proof.
The decision at stage 3: every connection is scored from who you are, the health of your device, and the context of the request — never trusted by network location. Anything unknown fails closed.
The moments that make the platform — the phone approval, the device console, and the experience dashboard — recreated here so they animate live in the docs. Switch surface:
Push approval, number-matched. A sign-in request reaches the phone — even on the lock screen. One tap on the matching number, one biometric check, and a hardware-backed key signs it. QR sign-in is the same card with a single tap.
The device console, on the endpoint. Eleven live health signals scored into a trust
tier — the same model the broker uses — with a grounded fix for anything failing, and a one-click
always-on switch. Runs locally at 127.0.0.1:47010.
Digital experience, per app and per person. Reachability, latency (DNS / connect / TTFB) and loss — from a datacentre prober and from real endpoints — scored 0–100 by a deterministic formula, rolled up to availability, p50/p95 and the worst-experiencing user.
The ideas the rest of the docs build on — each is a capability you can enable independently.
A single control plane mediates every request. Nothing is trusted by network location — identity, device and context are checked on each connection, and the default is deny.
A connector is an outbound agent inside a network segment; a Site groups connectors into a routing domain. Overlapping address space is handled by longest-prefix routes — and a connector's site is fixed at enrolment, never self-asserted.
Per-app mode opens an on-demand tunnel to one app with no client change. Always-on puts a virtual adapter on the endpoint and carries every protected connection transparently — each flow still authorised fail-closed by the same policy.
Egress control on the decrypted path: private DNS filtering, selective TLS inspection, and content-based file-type control with sandbox submission — by category, not just by hostname.
Reachability, latency (DNS / connect / TTFB) and loss to the apps that matter — from a datacentre prober and from real endpoints — scored deterministically from 0 to 100.
A token is locked to a silent hardware-class key, so a stolen token is useless off the device. Opportunistic by default so it never breaks a browser app; enforced once every device carries a bound key.
Eleven health signals score a device into a trust tier with tunable weights and a grounded remediation coach. A device that can't prove a signal is treated as less trusted, and unknown posture fails closed to capped access.
A signed, append-only record of every decision and admin change, off the hot path — so the platform can prove what happened, not merely assert it.
AI agents get first-class identities, short-lived tokens bound to an owner-enrolled device, per-tool authorisation through an MCP broker, and signed provenance receipts.
Pramaan ID runs entirely inside your network. It ships as a single container; everything below is self-hosted — no external services, no data leaving your perimeter.
docker compose up -d broker starts it.DATABASE_URL. State (users, clients, sessions, monitors, log) lives here; the broker runs its own migrations at startup.BROKER_BIND. The public URL is BROKER_ISSUER.BROKER_SIGNING_KEY_FILE) so the JWKS key-ids stay stable across restarts — federation depends on it.DATABASE_URL.BROKER_SIGNING_KEY_FILE; set BROKER_ISSUER to your public HTTPS URL.docker compose up -d broker. It migrates the database and serves the IdP, admin and account UIs.BROKER_BIND.# docker-compose.yml — minimal broker
services:
broker:
image: pramaan/broker:latest
restart: unless-stopped
environment:
# Public HTTPS URL your reverse proxy serves (the token issuer).
BROKER_ISSUER: https://id.example.com
# External Postgres (state lives here; the broker migrates on start).
DATABASE_URL: postgres://pramaan:secret@db:5432/pramaan
# Stable signing key -> stable JWKS key-ids across restarts.
BROKER_SIGNING_KEY_FILE: /keys/broker_es256.pem
BROKER_BIND: 0.0.0.0:8080
# Gated admin console on its own listener (never the public socket).
BROKER_ADMIN_BIND: 0.0.0.0:9444
volumes:
- ./keys/broker_es256.pem:/keys/broker_es256.pem:ro
ports:
- "8080:8080" # put your TLS-terminating reverse proxy in front
- "9444:9444" # admin console (restrict to your management network)Every capability past this point is gated and off by default — turn on exactly what you need using the guides below.
The shortest path from an empty server to a posture-aware sign-in reaching a private app. Do these in order; each links to the full guide.
Point DATABASE_URL at Postgres, mount a signing key, set BROKER_ISSUER, and docker compose up -d broker. It migrates the database and serves the IdP and consoles.
Terminate HTTPS at nginx or Caddy and forward to BROKER_BIND. Your public URL is the issuer every app will trust — keep the signing key file-mounted so it never changes.
Register your first app under Applications — an OIDC client (recommended) or a SAML provider — and connect a directory source so users and groups flow in. Sign in end-to-end before going further.
Enrol a connector beside an internal app, register the app, and add an access rule — an app with zero rules fails closed. Open it from the client with no public exposure.
Everything else is gated and off by default: device-posture gating, experience monitoring, the web gateway, AI-agent identities. Flip one capability at a time using its guide.
Plain-language definitions for every term and open standard Pramaan uses. Where a capability follows a published RFC, it's linked and explained.
The industry-standard framework that lets an app get limited, delegated access on a user's behalf without ever seeing their password.
Instead of handing an app your password, you're redirected to Pramaan to sign in; the app receives a short-lived access token scoped to only what it needs. RFC 6749 defines the roles, grant types and token endpoints. Pramaan uses the authorization-code grant for user sign-in and client-credentials for machine-to-machine and AI agents.
A thin identity layer on top of OAuth 2.0. It adds a signed ID token that proves who signed in — the modern way apps do single sign-on.
OAuth 2.0 alone answers “what can this app do?”, not “who is this?”. OIDC adds a signed ID token carrying verified claims (subject, email, groups) and a discovery document so apps auto-configure. It's the recommended way to integrate a new app with Pramaan.
“Proof Key for Code Exchange.” Stops an intercepted sign-in code from being reused by anyone but the app that started the flow.
A public app can't keep a secret, so an attacker who intercepts the authorization code could redeem it. PKCE fixes this: the app sends only the hash of a random secret when starting sign-in and must present the original to redeem the code. Pramaan requires PKCE on every public client.
Binds a token to a secret key held on one device, so a stolen token is useless anywhere else.
“Demonstrating Proof-of-Possession.” The client holds a private key and attaches a small signed proof to each request; Pramaan issues the token bound to that key's fingerprint and later refuses the token without a matching proof. Pramaan uses a silent hardware-class key (no extra prompt) and rolls it out opportunistically so it never breaks browser apps.
An older but widely used single-sign-on standard (XML-based). Pramaan is a SAML identity provider for apps that speak it instead of OIDC.
SAML exchanges signed XML assertions about a user between the identity provider (Pramaan) and the app. Many enterprise and legacy apps only support SAML — Pramaan is a full SAML IdP so they work unchanged alongside your OIDC apps.
A standard for automatically creating, updating and de-provisioning user accounts in downstream apps, so joiners and leavers stay in sync.
SCIM defines a REST API and a common user/group schema so an identity source can push account changes automatically. When someone joins, changes role or leaves, Pramaan provisions or de-provisions them in connected apps — no manual account admin, no orphaned accounts.
The classic directory protocol. Pramaan reads users and groups from a directory (e.g. FreeIPA) and can present a directory for legacy apps that require one.
Pramaan works with LDAP both ways: as a sync source (importing users and groups from FreeIPA or Active Directory) and as an LDAP server so older apps that can only authenticate against a directory can still use Pramaan identities.
The passkey standard: phishing-resistant sign-in with a device biometric or security key. The Pramaan app can itself act as a passkey.
WebAuthn (W3C) plus CTAP (FIDO2) let a device create a unique key pair per site, unlocked with a biometric or PIN. Because the key is bound to the real site's origin, passkeys can't be phished or replayed. On Android 14+ the Pramaan app is a system passkey provider.
An append-only, cryptographically verifiable record — the same Merkle-tree idea behind Certificate Transparency.
Entries chain into a Merkle tree: each new entry changes a single root hash summarising the whole log, so nothing can be altered or removed after the fact without detection. Pramaan records every decision and admin change this way, so you can prove what happened.
An open standard for giving AI agents access to tools. Pramaan brokers that access per-tool, so an agent only reaches what it's authorised for.
MCP standardises how an agent discovers and calls external tools and data. Pramaan sits in front as a tool broker: each agent has its own identity, and every tool call is authorised and logged — so an agent uses exactly the tools it's been granted, nothing more.
Reaching private apps without a traditional VPN: nothing is trusted by network location, and each connection is authorised on its own merits.
A VPN drops you onto the network and trusts everything after. ZTNA inverts it: apps stay invisible (no public address, no inbound ports), and every connection is authenticated and authorised against identity, device posture and policy before it's allowed — and re-checked continuously.
Delivering network security — web gateway, DNS filtering, inspection — as part of the access path rather than as separate appliances.
The egress control point: it filters DNS, can inspect TLS, and blocks disallowed file types — by category, not just by hostname.
As traffic leaves for the internet, the gateway can filter DNS, optionally decrypt and inspect TLS to enforce policy on content, and block file types by their actual bytes. It's category-driven, so you manage intent (“no file-sharing”) rather than endless host lists.
Shared-signals events that let Pramaan tell an app to end a session the moment a credential is revoked — instead of waiting for the next login.
CAEP, part of the OpenID Shared Signals Framework, pushes signed events (session-revoked, credential-changed) to receivers. Pramaan emits them on enrol and revoke so connected apps react in near real time to a lost device or an off-boarded user.
A virtual network adapter the always-on client creates. The OS routes protected traffic into it, and the client tunnels each connection to the broker.
A TUN is a software network interface that hands raw IP packets to a program instead of a physical wire. The always-on client creates one, routes protected addresses through it, then tunnels each connection to the broker — transparent to the apps, with no per-app setup.
“JSON Web Key Set” — the public keys apps use to verify the tokens Pramaan issues.
Pramaan publishes its public signing keys at a well-known URL; each key has a stable key id (kid). Apps fetch these to verify token signatures. Keeping the kids stable across restarts is exactly what lets already-integrated apps keep trusting Pramaan without reconfiguration.
Every capability below is coded and ready — most ship gated and switched off, so you turn on exactly what you need. The badge on each shows its state; the How to enable card names the exact toggle. Nothing is hidden just because it isn't switched on yet.
| Capability | Area | Status | Primary toggle |
|---|---|---|---|
| Desktop client | Client apps | Core | install / always on |
| Mobile app | Client apps | Core | install / always on |
| Identity — OIDC, SAML, SCIM & LDAP | Identity & MFA | Core | BROKER_ISSUER |
| Operations & rollback | Operate | Core | BROKER_SIGNING_KEY_FILE |
| Transparency log & CAEP | Monitoring & AI | On by default | BROKER_CAEP_ENABLE |
| Multi-site & VRF routing | Zero-Trust access | On by default | BROKER_ZTNA_TRUST_ADVERTISED_ROUTES |
| MFA, passkeys & device-bound tokens | Identity & MFA | Available · off | BROKER_DPOP_ENFORCE |
| Device posture & trust tiers | Identity & MFA | Available · off | BROKER_AGENT_POSTURE_ENABLE |
| Digital Experience Monitoring | Monitoring & AI | Available · off | BROKER_DEM_ENABLE |
| Identity for AI agents & MCP | Monitoring & AI | Available · off | BROKER_AGENTS_ENABLE |
| Policy Copilot (on-prem) | Monitoring & AI | Available · off | BROKER_COPILOT_ENABLE |
| Risk-response runbooks | Monitoring & AI | Available · off | BROKER_RUNBOOKS_ENABLE |
| ZTNA — private apps & connectors | Zero-Trust access | Available · off | BROKER_CONNECTOR_BIND |
| SSH to a private VM (raw-TCP how-to) | Zero-Trust access | Available · off | BROKER_TCP_TUNNEL_BIND |
| Always-on access (L3 tunnel) | Zero-Trust access | Available · off | BROKER_ZTNA_L3_ENABLE |
| Secure Web Gateway & DNS filtering | Zero-Trust access | Available · off | BROKER_SWG_ENABLE |
| File-type control & sandbox | Zero-Trust access | Available · off | BROKER_SWG_FILETYPE_BLOCK |
One map of where every capability is switched on. A feature can touch up to five layers — from the client build all the way to the admin console — and this table names the exact switch at each. A dash means that layer isn't involved. Every switch shown is real and verified against the source; capabilities that can't yet be turned on from a given layer are simply absent.
| Capability | Compile-timeA cargo feature baked into the client build | Desktop consoleA control in the desktop tray app | Broker flagA BROKER_* env var on the broker | Connector envA CONNECTOR_* env var on the connector | Admin consoleConfigured in the web admin console |
|---|---|---|---|---|---|
| Identity — OIDC / SAML / LDAP | — | — | BROKER_ISSUER · BROKER_LDAP_BIND | — | Applications · Sources · Property mappings |
| MFA, passkeys & device-bound tokens | — | Mobile app is a passkey | BROKER_DPOP_ENFORCE · BROKER_DPOP_ENFORCE_API | — | Flows · self-service account portal |
| Device posture & trust tiers | — | Reports automatically once enrolled | BROKER_AGENT_POSTURE_ENABLE · BROKER_POSTURE_UNKNOWN_FAIL_CLOSED | — | Posture (weights, min score, unknown action) |
| ZTNA — private apps & connectors | — | Raw-TCP port forward (SSH/RDP/PG) | BROKER_CONNECTOR_BIND · BROKER_CONNECTOR_ENABLE · BROKER_TCP_TUNNEL_BIND | Enrol token + CONNECTOR_UPSTREAMS / CONNECTOR_ALLOWED_CIDRS | Connectors · Applications · Access rules |
| Multi-site & VRF routing | — | — | Static routes (authoritative) · BROKER_ZTNA_CONNECTOR_STALE_SECS | CONNECTOR_SITE · CONNECTOR_ROUTES · CONNECTOR_ADVERTISE_SECS | Sites · Static routes · metric / accept-learned |
| BGP-at-connector (learned routes) | — | — | BROKER_ZTNA_TRUST_ADVERTISED_ROUTES (accept learned) | CONNECTOR_BGP_ENABLE + CONNECTOR_BGP_PEER / _PEER_ASN / _LOCAL_ASN / _ROUTER_ID | Sites → learned routes (read-only view) |
| Transparent access (always-on L3) | Desktop l3 cargo feature | “Start transparent access” | BROKER_ZTNA_L3_ENABLE · BROKER_TCP_TUNNEL_BIND | Standard ZTNA connector | Applications (host:port apps → fake-IP) |
| SASE — Secure Web Gateway & DLP | — | — | BROKER_SWG_ENABLE · BROKER_SWG_DNS_BIND · BROKER_SWG_PROXY_BIND | — | SWG policy · categories · DLP keywords |
| Digital Experience Monitoring | — | Agent metrics once enrolled | BROKER_DEM_ENABLE · BROKER_DEM_SYNTHETIC_SECS | — | Experience dashboard · synthetic monitors |
| AI agents & MCP tool-broker | — | — | BROKER_AGENTS_ENABLE · BROKER_MCP_ENABLE · BROKER_AGENTS_PROVENANCE | — | Agent registry |
| Policy Copilot & Advisor | — | — | BROKER_COPILOT_ENABLE · BROKER_COPILOT_URL | — | Policy Copilot (propose → verify → record) |
| Runbooks & risk events | — | — | BROKER_RUNBOOKS_ENABLE · BROKER_RUNBOOKS_AUTO | — | Risk events · Runbooks |
| Transparency log, CAEP & SSF | — | — | On by default (tamper-evident log) | — | Audit · session-revocation events |
Most features live entirely on the broker + admin console. The client layers
(compile-time, desktop console) apply only to the desktop-driven datapath — transparent
L3 access needs the l3 build and its broker flag and the console toggle,
all three. Turn a capability on at the broker first; the admin console is where you then shape it.
The identity provider at the centre of everything. Apps sign in with OpenID Connect (authorization-code + PKCE, rotating refresh, RP-initiated logout) or the native SAML 2.0 IdP. Users and groups flow in from your directory (LDAP / FreeIPA), provision outward with SCIM, and legacy apps can bind against a built-in LDAP server. Everything is managed in the admin console.
roles / groups claims with property mappings. A fresh install comes seeded with the standard mapping set (roles, groups, ldap_ipa_roles, login, user_attributes, entitlements) already present and editable — a deleted one is never re-created, so your changes stick.BROKER_LDAP_BIND) and give them the read-only service bind DN.BROKER_SCOPE_ENFORCE so every client only ever receives the scopes it is registered for.| Variable | What it does | Default |
|---|---|---|
BROKER_ISSUER | Public HTTPS base URL that identifies this broker as an OIDC issuer. | http://localhost:8080 |
BROKER_SCOPE_ENFORCE | Confines each request's scopes to the client's registered allow-list (openid always kept). | false |
BROKER_FEDERATION_CLAIM_ENRICH | Activates identity-claim enrichment on the authentik federation path (the cutover switch). | false |
BROKER_ADDITIONAL_VERIFY_KEYS_FILE | Extra public keys published in /jwks for zero-gap signing-key rotation. | — |
BROKER_SAML_KEY_FILE | RSA assertion-signing key; with its cert it turns on the native SAML 2.0 IdP. | — |
BROKER_LDAP_BIND | Binds the LDAP-server listener so legacy apps can authenticate against Pramaan. | — (disabled) |
# Tighten token scopes + trust an extra verify key during rotation
BROKER_SCOPE_ENFORCE: "true" # only issue a client's registered scopes
BROKER_ADDITIONAL_VERIFY_KEYS_FILE: /keys/next_pub.pem
# Native SAML IdP (both key + cert enable it):
BROKER_SAML_KEY_FILE: /keys/saml_signing.pem
BROKER_SAML_CERT_FILE: /keys/saml_signing_cert.pemStrong, phishing-resistant sign-in without the friction. Users approve a push with number-matching, scan a QR for one-tap sign-in, or use a passkey — the mobile app is itself a passkey. Behind the scenes every token is bound to a silent hardware-class key (DPoP, RFC 9449), so a stolen token is useless on another device. Binding is opportunistic by default — it never breaks a browser app — and only enforced once every device carries a bound key.
FCM_SERVICE_ACCOUNT_FILE) and hand out the mobile app.BROKER_PASSWORD_LOGIN; enable lost-device recovery with BROKER_RECOVERY_ENROL.BROKER_AUTH_LOCKOUT_THRESHOLD (default 10) wrong passwords/codes for an account in 15 minutes, further attempts are refused until they age out, a correct login clears it, and a brute-force risk event is recorded. Set BROKER_AUTH_IP_LOCKOUT_THRESHOLD to also lock by source IP (off by default — NAT-unsafe).BROKER_DPOP_ENFORCE=true (walk the enforce checklist first).BROKER_DPOP_ENFORCE_API=true, so a stolen device-bound token can't be replayed against the core /api endpoints — flip it only after every device runs a client that sends /api proofs.| Variable | What it does | Default |
|---|---|---|
BROKER_DPOP_ENFORCE | Requires a valid device-bound DPoP proof on /token, /userinfo and /introspect. | false |
BROKER_DPOP_ENFORCE_API | Extends DPoP enforcement to the core /api endpoints (applications, ZTNA apps, devices, sessions) and /session/handoff; a device-bound token must present a matching proof there. Unbound tokens are exempt, so browser portals are unaffected. Flip only after the mobile and desktop clients that send /api proofs are rolled out. | false |
BROKER_ADAPTIVE_STEPUP | Risk-based step-up: asks for MFA on impossible travel, bad reputation or an unfamiliar country. | false |
BROKER_AUTH_LOCKOUT_THRESHOLD | Failed-credential lockout: after this many wrong passwords/TOTP codes for one account within 15 minutes, further attempts are refused until the failures age out (0 disables). A correct login clears it. Also fires a brute-force risk event. | 10 |
BROKER_AUTH_IP_LOCKOUT_THRESHOLD | Optional per-source-IP failed-credential lockout, to catch password-spraying across many accounts from one IP (0 = off). Off by default because NAT'd networks share an IP — enable only where clients aren't behind shared NAT. | 0 (off) |
BROKER_APPROVAL_TTL_SECS | How long a pending push approval (and its auth code) stays valid. | 120 |
BROKER_PASSWORD_LOGIN | Offers username + password (live FreeIPA bind) as an alternate sign-in method. | false |
BROKER_RECOVERY_ENROL | Lets a single-use recovery code authorise enrolling a replacement device. | false |
# Device-bound tokens + adaptive step-up
BROKER_DPOP_ENFORCE: "false" # keep opportunistic during rollout; flip once devices carry keys
BROKER_ADAPTIVE_STEPUP: "true" # ask for MFA only when risk warrants it
BROKER_APPROVAL_TTL_SECS: "120"
BROKER_RECOVERY_ENROL: "true" # allow a recovery code to enrol a replacement deviceEvery device is scored on eleven health signals — disk encryption, firewall, screen lock, patch level, EDR/antivirus, Secure Boot, TPM, automatic updates, central management, and (on macOS) System Integrity Protection and Gatekeeper. The score maps to a trust tier, and access rules can require a tier per app. Signals are reported signed with a background key, so posture costs no extra biometric prompts, and a device that can't prove a signal is treated as less trusted — never more.
BROKER_AGENT_POSTURE_ENABLE=true so desktop clients can report.BROKER_POSTURE_UNKNOWN_FAIL_CLOSED=true and choose deny or step_up.BROKER_POSTURE_MAX_AGE_SECS) and cap non-trusted session lifetime (BROKER_LIMITED_TIER_TTL_SECS).| Variable | What it does | Default |
|---|---|---|
BROKER_AGENT_POSTURE_ENABLE | Enables the /agent/posture ingest endpoint the desktop client reports signals to. | false |
BROKER_POSTURE_UNKNOWN_FAIL_CLOSED | Turns an Allow into a Deny for unknown-posture devices on ZTNA / forward-auth. | false |
BROKER_POSTURE_UNKNOWN_ACTION | What fail-closed does when it fires — deny or step_up. | deny |
BROKER_POSTURE_MAX_AGE_SECS | Age after which a posture snapshot is treated as tier unknown; 0 never expires it. | 0 (off) |
BROKER_LIMITED_TIER_TTL_SECS | Caps session lifetime for non-trusted devices; 0 leaves standard TTLs. | 0 (off) |
BROKER_KEY_ATTESTATION_ENABLE | Verifies Android Key Attestation chains so hardware-backed is proven, not self-asserted. | false |
# Report ingest + how unknown devices are treated
BROKER_AGENT_POSTURE_ENABLE: "true"
BROKER_POSTURE_UNKNOWN_FAIL_CLOSED: "true"
BROKER_POSTURE_UNKNOWN_ACTION: "step_up" # deny | step_up
BROKER_LIMITED_TIER_TTL_SECS: "3600" # cap non-trusted session lifetime (0 = off)Reach internal apps with nothing exposed to the internet. A lightweight connector runs beside the app and dials OUT to the broker over pinned TLS — no inbound ports on the app network. The broker authenticates the user, checks device posture against the app's access rules, and pipes the traffic through the connector, which enforces its own upstream allow-list. Browsers reach web apps directly; the desktop client forwards a raw port for SSH, RDP or Postgres.
BROKER_CONNECTOR_BIND) with its TLS cert and set a registration key.BROKER_CONNECTOR_ENROLLED_ONLY=true so a leaked shared key can't join.BROKER_CONNECTOR_ENABLE=true, then register each private app (web or raw-TCP).| Variable | What it does | Default |
|---|---|---|
BROKER_CONNECTOR_BIND | Binds the gated listener that dark-app connectors dial into over pinned TLS. | — |
BROKER_CONNECTOR_ENABLE | Installs the edge that reverse-proxies browser requests to registered private web apps. | false |
BROKER_CONNECTOR_REGISTRATION_KEY_FILE | Shared key a connector must present in its HELLO (constant-time checked). | — |
BROKER_CONNECTOR_ENROLLED_ONLY | Accepts only per-connector enrolled tokens, refusing the shared registration-key bootstrap. | false |
BROKER_TCP_TUNNEL_BIND | Binds the gated raw-TCP ingress (SSH / RDP / Postgres over ZTNA) for the client forwarder. | — |
# Connectors dial OUT to these listeners; nothing inbound on app networks
BROKER_CONNECTOR_BIND: 0.0.0.0:9445 # connector registration (pinned TLS)
BROKER_CONNECTOR_ENABLE: "true" # browser edge for web private apps
BROKER_CONNECTOR_ENROLLED_ONLY: "true" # refuse the shared-key bootstrap after enrolment
BROKER_TCP_TUNNEL_BIND: 0.0.0.0:9446 # raw-TCP ingress (SSH/RDP/Postgres)# Enrol a connector in the admin console to get its one-time token,
# then run it next to the app. Nothing inbound opens on this network.
docker run -d --name pramaan-connector --restart unless-stopped \
-e CONNECTOR_BROKER_ADDR=id.example.com:9445 \
-e CONNECTOR_REGISTRATION_KEY_FILE=/run/secrets/conn_key \
-e CONNECTOR_SITE=hq-dc1 \
-e CONNECTOR_UPSTREAMS=10.20.0.10:22,10.20.0.10:5432 \
-e CONNECTOR_ALLOWED_CIDRS=10.20.0.0/24 \
pramaan/connector:latest
# CONNECTOR_UPSTREAMS — the only hosts:ports this connector will reach (its allow-list)
# CONNECTOR_SITE — which routing domain it belongs to (VRF-aware)
# CONNECTOR_BROKER_CA_FILE / _SNI — pin the broker's TLS cert on locked-down networksA worked example of the raw-TCP path: reach sshd on an internal Linux VM that today only answers over the VPN, with no VPN and nothing exposed to the internet. A connector inside the VM's network dials OUT to the broker and will only ever reach the one host:port on its allow-list. Your machine runs a small forwarder that opens a loopback port; your SSH client (OpenSSH, Termius, PuTTY) connects to 127.0.0.1 and the broker authorises every connection against the app's group and your device posture before a single byte flows. SSH's own host-key check and encryption run end-to-end inside the tunnel, unchanged. The same recipe covers RDP and Postgres — only the port changes.
CONNECTOR_UPSTREAMS=tcp://<vm-ip>:22 — this allow-list is the whitelisting: the connector can dial that host and port and nothing else.BROKER_TCP_TUNNEL_BIND, and make sure it is reachable by clients (front it with a TLS-passthrough listener on a public port; the default bind is loopback-only).PUT /admin/tcp-apps or the console): an id, the upstream tcp://<vm-ip>:22, the connector_id, and the groups allowed to reach it.pramaanctl forward ssh-vm 2222 then ssh user@127.0.0.1 -p 2222. Android: open the app, ZTNA apps → your SSH app → Forward, then point Termius at 127.0.0.1:2222.pramaanctl forward ssh-vm 2222 | Open 127.0.0.1:2222 and tunnel it to the ssh-vm app through the broker |
ssh user@127.0.0.1 -p 2222 | Connect your SSH client to the forwarded local port |
| Variable | What it does | Default |
|---|---|---|
PRAMAAN_TCP_TUNNEL_ADDR | host:port of the broker's raw-TCP ingress the forwarder dials (pinned TLS) | — |
FORWARDER_UPSTREAMS | standalone forwarder only: the local_port:app_id pairs to open | — |
| Variable | What it does | Default |
|---|---|---|
BROKER_TCP_TUNNEL_BIND | Binds the gated raw-TCP ingress (SSH / RDP / Postgres over ZTNA) for the client forwarder. | — |
BROKER_TCP_TUNNEL_REQUIRE_SCOPE | Requires the tunnel token to carry the pramaan.tunnel scope. | false |
BROKER_CONNECTOR_BIND | Binds the gated listener that dark-app connectors dial into over pinned TLS. | — |
BROKER_CONNECTOR_ENROLLED_ONLY | Accepts only per-connector enrolled tokens, refusing the shared registration-key bootstrap. | false |
The connector's CONNECTOR_UPSTREAMS allow-list is the real security boundary: even a fully compromised broker can only ask the connector to reach a host:port already on that list, so scoping it to tcp://<vm-ip>:22 means SSH and nothing else. Keep the app's group tight and let posture do the rest.
# 1. Run a connector inside the VM's network (or on the VM itself).
# Enrol it under Connectors first to get its one-time token.
docker run -d --name pramaan-ssh-connector --restart unless-stopped \
-e CONNECTOR_ID=vm-ssh-connector \
-e CONNECTOR_BROKER_ADDR=id.example.com:9445 \
-e CONNECTOR_REGISTRATION_KEY_FILE=/run/secrets/conn_key \
-e CONNECTOR_UPSTREAMS=tcp://10.20.0.9:22 \
-e CONNECTOR_BROKER_CA_FILE=/etc/pramaan/broker-ca.pem \
pramaan/connector:latest
# CONNECTOR_UPSTREAMS — the ONLY target this connector will ever dial (SSH, one host:port).
# If the connector runs ON the VM, use tcp://127.0.0.1:22.
# A destination not in this allow-list is refused — a compromised broker cannot pivot.# 2. Register the SSH app (admin API or console). Same shape as any raw-TCP app.
# id/upstream/connector + the group allowed to reach it. Posture rules apply on top.
PUT /admin/tcp-apps
{
"id": "ssh-vm",
"name": "SSH — Linux VM",
"upstream": "tcp://10.20.0.9:22",
"connector_id": "vm-ssh-connector",
"groups": ["ztna-ssh-vm"]
}
# 3a. Desktop — forward a local port, then point your SSH client at it.
export PRAMAAN_TCP_TUNNEL_ADDR=id.example.com:9446 # the raw-TCP ingress
pramaanctl login
pramaanctl forward ssh-vm 2222 # opens 127.0.0.1:2222
ssh user@127.0.0.1 -p 2222 # the VM's host key is pinned as usual
# 3b. Android (Termius on the same phone) — start the forwarder in the Pramaan app:
# ZTNA apps -> "SSH - Linux VM" -> Forward. It shows 127.0.0.1:2222.
# In Termius: Host 127.0.0.1, Port 2222. Keep Pramaan running in the background.Real networks span multiple routing domains, often with overlapping private address space. A Site groups the connectors that share an address space. A private app either pins a specific connector or targets a Site — and the broker picks a connector in that site by longest-prefix match against the destination, so identical prefixes in different sites never collide. A connector's site is fixed at enrolment (it can't claim another site's traffic), admin static routes are authoritative, and anything unroutable fails closed. Beyond longest-prefix, routing is health- and preference-aware: give equally-specific routes a metric for active/standby (lower wins), and a connector that misses heartbeats is automatically demoted below healthy peers but kept as failover. A site can also opt into gated dynamic learning, where connectors' advertised routes fill gaps the static routes don't cover — but only as a gap-filler that never overrides a static route and is withdrawn when the connector goes silent, so a compromised connector still can't hijack a prefix. Unlike a BGP peer, which is trusted to inject and can blackhole, a learned route here is bounded, aged and provably unable to steal traffic a static route already carries.
10.1.0.0/16, an exact IP, a .suffix domain, a default 0.0.0.0/0, or *.BROKER_ZTNA_CONNECTOR_STALE_SECS.BROKER_ZTNA_TRUST_ADVERTISED_ROUTES=true for the whole fleet). Learned routes only fill gaps, never override static, and age out after the site's withdraw window.CONNECTOR_ADVERTISE_SECS, default 30s) rather than only at connect time, so a route a connector stops advertising is withdrawn from routing even while its tunnel stays up — freshness is measured from the last advertisement, not from tunnel keepalives. Keep the cadence comfortably under the site's withdraw window.CONNECTOR_BGP_ENABLE=true plus CONNECTOR_BGP_PEER, CONNECTOR_BGP_PEER_ASN, CONNECTOR_BGP_LOCAL_ASN and CONNECTOR_BGP_ROUTER_ID. The connector opens ONE learn-only session — it never advertises anything back — and feeds the learned prefixes into the same gated learned-route channel above. Bound what it may accept with CONNECTOR_BGP_IMPORT_ALLOW (allowed supernets), CONNECTOR_BGP_MAX_PREFIXLEN and CONNECTOR_BGP_MAX_PREFIXES (the session is torn down on overflow); a default route is never learned. A learned route's BGP MED becomes its route metric (lower = preferred), so among equally-specific learned paths the lower-MED connector leads and the rest are failover — just like a static route's metric. So even a misbehaving peer can only gap-fill within the connector's own site, never override a static route or reach another VRF — the safety a raw BGP feed can't offer.| Variable | What it does | Default |
|---|---|---|
BROKER_ZTNA_TRUST_ADVERTISED_ROUTES | Lets connector-declared routes be used for routing fleet-wide (otherwise only admin static routes, or a per-site opt-in). Learned routes only fill gaps and never override a static route. | false |
BROKER_ZTNA_CONNECTOR_STALE_SECS | Health-aware routing: a connector idle longer than this (seconds) is demoted below healthy peers of the same route specificity/metric, kept only as failover. | 90 |
BROKER_CONNECTOR_ENROLLED_ONLY | Accepts only per-connector enrolled tokens, refusing the shared registration-key bootstrap. | false |
# Routing across VRFs / overlapping address space
BROKER_ZTNA_TRUST_ADVERTISED_ROUTES: "false" # default: only admin static routes carry traffic
# (Sites, static routes and per-app routing are configured in the admin console.)Per-app mode opens a tunnel to one app on demand. Always-on mode makes every protected app reachable transparently: the desktop client creates a virtual network adapter, the OS routes protected addresses into it, and each connection is tunnelled to the broker with no per-app step. The broker resolves the destination to an authorised segment and applies the same access rules — a raw socket carries no posture, so it is treated as unknown and default-deny applies unless a rule admits it. Every flow is still authorised fail-closed.
BROKER_ZTNA_L3_ENABLE=true and bind the raw-TCP tunnel (BROKER_TCP_TUNNEL_BIND).BROKER_TCP_TUNNEL_REQUIRE_SCOPE=true.| Variable | What it does | Default |
|---|---|---|
BROKER_ZTNA_L3_ENABLE | Enables always-on L3 ingress: an app-less tunnel that carries only a destination is auto-resolved to a segment. | false |
BROKER_TCP_TUNNEL_BIND | Binds the gated raw-TCP ingress (SSH / RDP / Postgres over ZTNA) for the client forwarder. | — |
BROKER_TCP_TUNNEL_REQUIRE_SCOPE | Requires the tunnel token to carry the pramaan.tunnel scope. | false |
# Always-on transparent access (app-less ingress)
BROKER_ZTNA_L3_ENABLE: "true"
BROKER_TCP_TUNNEL_BIND: 0.0.0.0:9446
BROKER_TCP_TUNNEL_REQUIRE_SCOPE: "true" # require the pramaan.tunnel scope on the tokenControl what leaves the network, not just what comes in. The gateway authenticates the caller, categorises the destination, runs the same access-rule engine, and egresses through an anti-SSRF guard. A private DNS filter can forward or sinkhole a lookup by category, and selective HTTPS inspection decrypts only the categories you allow — with a broker-minted per-site leaf and a separately verified origin — so inline DLP and file-type control can see the real content. Verdicts fail closed by default.
BROKER_SWG_ENABLE=true. For the web proxy add BROKER_SWG_PROXY_BIND; for DNS filtering add BROKER_SWG_DNS_BIND.BROKER_SWG_CATEGORY_DIR) and write access rules — the proxy refuses to start if a configured feed fails to load.BROKER_DLP_ENABLE=true and an optional keyword file.BROKER_SWG_TLS_INSPECT=true and list the categories to decrypt — it never decrypts outside that list.| Variable | What it does | Default |
|---|---|---|
BROKER_SWG_ENABLE | Master gate for the Secure Web Gateway policy endpoint and its proxy / DNS listeners. | false |
BROKER_SWG_PROXY_BIND | Binds the inline HTTP forward-proxy data plane (needs SWG enabled). | — |
BROKER_SWG_DNS_BIND | Binds the gated private-DNS filter (categorise → forward or sinkhole). | — |
BROKER_SWG_CATEGORY_DIR | Directory of URL/domain category feeds that drive category-based policy. | — |
BROKER_SWG_TLS_INSPECT | Enables selective HTTPS inspection — only for Inspect-rule traffic in listed categories. | false |
BROKER_DLP_ENABLE | Master gate for the inline content-scan (/dlp/scan) endpoint. | false |
BROKER_SWG_FAIL_CLOSED | Makes SWG/DLP verdicts deny on store outage, rule error or no match. | true |
# Secure Web Gateway: web proxy + private DNS filter
BROKER_SWG_ENABLE: "true"
BROKER_SWG_PROXY_BIND: 127.0.0.1:3128
BROKER_SWG_DNS_BIND: 127.0.0.1:5353
BROKER_SWG_CATEGORY_DIR: /swg/categories # category rule feeds
BROKER_SWG_TLS_INSPECT: "true"
BROKER_SWG_TLS_INSPECT_CATEGORIES: "uncategorized,file-sharing"On the decrypted path the gateway identifies a file's true type from its magic bytes — never the Content-Type header or the extension — and blocks disallowed categories (executables, archives, scripts) or a declared-versus-actual spoof, in both directions. Active content can be submitted to an on-prem detonation sandbox: a definitive malicious verdict blocks, while a sandbox fault is best-effort and never silently fails open.
BROKER_SWG_FILETYPE_BLOCK (e.g. executable,archive,script).BROKER_SWG_FILETYPE_BLOCK_MISMATCH=true.BROKER_SWG_SANDBOX_URL at your sandbox; tune the buffered body cap with BROKER_SWG_INSPECT_MAX_BODY.| Variable | What it does | Default |
|---|---|---|
BROKER_SWG_FILETYPE_BLOCK | Magic-byte-sniffed file categories to block, e.g. executable, archive, script. | — (none) |
BROKER_SWG_FILETYPE_BLOCK_MISMATCH | Blocks a declared Content-Type that disagrees with the sniffed true type. | false |
BROKER_SWG_SANDBOX_URL | On-prem detonation sandbox to submit downloads to; a malicious verdict blocks. | — |
BROKER_SWG_INSPECT_MAX_BODY | Max response body buffered for inline DLP / sandbox inspection (bytes). | 10485760 |
# Content-based file-type control on the decrypted path
BROKER_SWG_FILETYPE_BLOCK: "executable,archive"
BROKER_SWG_FILETYPE_BLOCK_MISMATCH: "true" # block declared-vs-sniffed spoofing
BROKER_SWG_SANDBOX_URL: https://sandbox.internal/scanMeasure how it actually feels to reach the apps that matter — reachability, latency (DNS, connect, time-to-first-byte) and loss. Samples come from the endpoints your people work on (the real experience, over their authenticated channel) and, optionally, a broker-side synthetic prober from the datacentre. Every sample is scored 0–100 by a deterministic formula from the monitor's thresholds — never a model — and the console rolls it up per monitor: availability, p50/p95 latency, and the worst-experiencing user for triage.
BROKER_DEM_ENABLE=true to open the /dem ingest and the admin DEM API.host:port TCP target, with latency and loss thresholds.BROKER_DEM_SYNTHETIC_SECS (e.g. 60) so the broker probes it on a timer.pramaanctl dem.| Variable | What it does | Default |
|---|---|---|
BROKER_DEM_ENABLE | Enables the /dem ingest and admin DEM API; off means the routes 404 and nothing is recorded. | false |
BROKER_DEM_SYNTHETIC_SECS | Interval for the broker's synthetic prober; only probes monitors flagged synthetic. | 0 (off) |
# Digital Experience Monitoring
BROKER_DEM_ENABLE: "true"
BROKER_DEM_SYNTHETIC_SECS: "60" # broker probes synthetic monitors every 60s (0 = off)Give AI agents real, least-privilege identities instead of shared API keys. Each agent has a human owner, a scope ceiling, and short-lived tokens via the client-credentials grant — scoped to the intersection of request and ceiling, device-bound, with no refresh token. An MCP tool-broker authorises each tool call through the same policy engine, and every grant can emit an offline-verifiable signed receipt so you have a provable record of what an agent was allowed to do.
BROKER_AGENTS_ENABLE=true to open the agent registry and the client-credentials grant.BROKER_AGENTS_REQUIRE_ENROLLED_DEVICE=true so an agent secret alone is not enough — the key must be an owner-enrolled device.BROKER_MCP_ENABLE=true, and signed receipts with BROKER_AGENTS_PROVENANCE=true.| Variable | What it does | Default |
|---|---|---|
BROKER_AGENTS_ENABLE | Enables the agent registry and the client_credentials grant for AI-agent identities. | false |
BROKER_AGENTS_REQUIRE_ENROLLED_DEVICE | Requires an agent's DPoP key to belong to an owner-enrolled device before minting a token. | true |
BROKER_MCP_ENABLE | Enables the /mcp/authorize per-tool Allow/Deny broker. | false |
BROKER_AGENTS_PROVENANCE | Adds a signed ES256 provenance receipt to every agent token-mint and tool authorisation. | false |
# Identity for AI agents + the MCP tool broker
BROKER_AGENTS_ENABLE: "true"
BROKER_AGENTS_REQUIRE_ENROLLED_DEVICE: "true" # secret alone is never enough
BROKER_MCP_ENABLE: "true"
BROKER_AGENTS_PROVENANCE: "true" # signed action receiptsA self-hosted small language model that proposes policy and agent drafts from plain English — while a deterministic engine verifies every draft against a closed grammar and the log records a reproducibility receipt (model, prompt and grammar hashes). The model is advisory and off the hot path, so a copilot outage never blocks a login. Prompts never leave your network: the broker refuses to start if the model URL isn't a private / loopback address.
BROKER_COPILOT_URL at it — it must be loopback / private / localhost, or the broker refuses to start.BROKER_COPILOT_ENABLE=true; tune the abstention floor with BROKER_COPILOT_MIN_CONFIDENCE.BROKER_COPILOT_MODEL_SHA256 so receipts attest a known model build.| Variable | What it does | Default |
|---|---|---|
BROKER_COPILOT_ENABLE | Master switch for the on-prem Policy Copilot (admin listener only). | false |
BROKER_COPILOT_URL | Base URL of the LOCAL SLM server — must be loopback / private / localhost or the broker refuses to start. | — |
BROKER_COPILOT_MODEL | Model id sent to the local SLM server. | qwen3-1.7b-instruct-q4_k_m |
BROKER_COPILOT_MIN_CONFIDENCE | Confidence floor below which the copilot abstains instead of guessing. | 0.0 (off) |
BROKER_COPILOT_MODEL_SHA256 | Model-weights hash recorded verbatim in a committed rule's reproducibility receipt. | — |
# On-prem Policy Copilot (advisory, off the hot path)
BROKER_COPILOT_ENABLE: "true"
BROKER_COPILOT_URL: http://127.0.0.1:8899 # MUST be loopback/private or the broker won't start
BROKER_COPILOT_MODEL: qwen3-1.7b-instruct-q4_k_m
BROKER_COPILOT_MIN_CONFIDENCE: "0.6" # abstain below thisWhen the broker detects a risk signal from its own state — a device's assurance tier dropping on a token refresh, a refresh token being replayed (probable theft), a device reporting itself non-compliant, repeated failed logins crossing the lockout threshold, or a physically impossible sign-in location — it maps the signal to a remediation and records it to the Risk events feed in the admin console. By default it is advisory: the platform surfaces what it would do and why, and an operator decides. From the same page you can override the remediation per signal, and opt a rule into auto-apply — then a quarantine or token-family revoke runs automatically, still behind a master gate. For finer control, attach a small sandboxed script to a signal that picks the action from the risk context (signal, subject, device tier and score) at the moment it fires. Every entry is a structured, ids-only audit record, so it joins the tamper-evident log with no attacker-influenced free text.
BROKER_RUNBOOKS_ENABLE=true; with it off, the engine is a complete no-op.rb context and returns one of none, step_up, revoke_family, quarantine_device. It is compiled and sandbox-checked when you save; a script that fails or returns an unknown value falls back to the fixed action, so it can never force an unexpected remediation.if rb.device_score < 40 { "quarantine_device" } else { "step_up" }. Combine conditions with && / || (not the words and/or), compare with ==, and note rhai has no ternary ? : operator — use if/else. The editor tells you exactly what won't compile.BROKER_COPILOT_ENABLE), use Draft a script with AI to describe the logic in plain language — the model proposes a script, the broker verifies it compiles and dry-runs it over sample devices, and you review that concrete behaviour before installing. Installed scripts are advisory; the model never enables auto-apply. If a draft can't compile (say the model reached for a ternary), the broker rejects it with a clear reason rather than installing anything.BROKER_RUNBOOKS_AUTO=true — without it, every rule stays advisory. Step-up never auto-applies.BROKER_REATTEST_ON_REFRESH so each refresh re-checks device posture — that keeps the tier current, so a real drop is detected promptly.| Variable | What it does | Default |
|---|---|---|
BROKER_RUNBOOKS_ENABLE | Turns on risk-response runbooks: when a device's assurance tier drops on refresh or a refresh token is reused, the broker records a recommended remediation to the Risk events feed. Advisory by default — see BROKER_RUNBOOKS_AUTO to let specific rules act. | false |
BROKER_RUNBOOKS_AUTO | Master gate for runbook auto-apply. Even with runbooks on, an imperative remediation (quarantine a device, revoke a token family) runs only when this is on AND the operator's rule for that signal is set to auto-apply. Off keeps every runbook advisory. Step-up is always advisory. | false |
BROKER_REATTEST_ON_REFRESH | Re-runs posture + policy on every refresh-token grant (continuous authorisation). | false |
An append-only, cryptographically verifiable record — the same Merkle-tree idea behind Certificate Transparency. Anyone can verify that an event is included and that history was never rewritten, and can verify a signed agent receipt, entirely in the browser against the public keys. Shared-signals (CAEP) streams push credential-change events on enrol and revoke, so connected apps can react to a revocation continuously instead of waiting for the next login.
BROKER_CAEP_ENABLE=true and register receivers.BROKER_SLHDSA_SECRET_FILE.DELETE /devices/{id} and the account portal's DELETE /api/devices/{id}. The actor is display-only and deliberately outside the receipt hash, so entries written before the field existed still verify.| Variable | What it does | Default |
|---|---|---|
BROKER_CAEP_ENABLE | Publishes the SSF configuration and pushes signed CAEP session-revoked events to receivers. | false |
BROKER_SLHDSA_SECRET_FILE | Post-quantum (FIPS 205) key that anchors the tamper-evident receipt chain head. | — |
# Continuous access (shared signals) + PQ receipt anchor
BROKER_CAEP_ENABLE: "true"
BROKER_SLHDSA_SECRET_FILE: /keys/slhdsa_secret.b64A small tray app and a command-line tool (pramaanctl) that turn a laptop into a trusted, Zero-Trust endpoint. It enrols the device with a hardware-backed key, reports device health, reaches private apps (a browser tab for web apps, a forwarded local port for SSH / RDP / Postgres, or transparent always-on), runs experience probes, and answers sign-in approvals — all from a local console at http://127.0.0.1:47010.
pramaanctl login then pramaanctl enroll — this registers a hardware-backed device key.pramaanctl console to see device health, reach apps, manage approvals and toggle always-on.PRAMAAN_BROKER (or pramaanctl config set-broker); it keeps itself current via signed auto-update.login | Browser sign-in (PKCE) with the broker. |
enroll [name] | Register this device's hardware-backed key with the broker. |
status / device | Show sign-in and enrolment state. |
list | List the private apps you can reach. |
open <app> | Open a web private app in the browser through a local tunnel. |
forward <app> <port> | Forward a local port to a raw-TCP app (SSH / RDP / Postgres). |
l3 | Start always-on transparent access (needs administrator). |
posture | Collect, sign and report device health; print the returned tier. |
dem | Run one experience-monitoring pass and report the samples. |
approvals / approve / deny | List and answer pending sign-in approvals. |
console | Open the local web console (port 47010). |
update [--apply] | Check for and install a newer signed release. |
| Variable | What it does | Default |
|---|---|---|
PRAMAAN_BROKER | Broker base URL | https://pramaanid.cloud |
PRAMAAN_DEM_DISABLE | Opt out of experience probing | — (on) |
PRAMAAN_DEM_INTERVAL_SECS | Experience probe interval | 300 |
PRAMAAN_POSTURE_INTERVAL_SECS | Posture report interval (floor 60s) | 600 |
# Point the client at your broker (or: pramaanctl config set-broker <url>)
PRAMAAN_BROKER=https://id.example.com
pramaanctl login && pramaanctl enroll
pramaanctl console # device health, apps, approvals, always-onThe Pramaan authenticator turns a phone into the primary sign-in factor. Approve a push with number-matching (even from the lock screen), scan a QR for one-tap sign-in, or use a built-in TOTP authenticator. Rich approval cards show the requesting browser, an approximate map pin, and a risk read; a tamper-evident history logs every approval. On Android 14+ the app is a system passkey provider. When pointed at a Pramaan broker it also unlocks the full Zero-Trust suite — enrolment, offline codes, device management, encrypted multi-device sync and experience probing.
SMS, WhatsApp and email codes are delivered by the broker during a browser sign-in — they are login options, not something the app itself sends. Passkeys and the Zero-Trust suite are gated: passkeys need Android 14+, and the broker features need a Pramaan-broker account. On a plain OIDC source the app is sign-in, an app launcher, and a TOTP vault.
# Add a Pramaan-broker account (unlocks the full Zero-Trust suite)
# 1. Open the app -> Add source -> scan the provisioning QR
# 2. Sign in, then enrol the device (one biometric check)
# Broker base for the reference deployment: https://pramaanid.cloudThe broker runs as a single container against an external Postgres. Redeploys are reversible: tag the running image as a rollback point, rebuild, and bring it up — a few-second blip. Because the signing key is file-mounted, the published key-ids stay stable across restarts and every already-integrated app keeps trusting the broker without reconfiguration.
BROKER_SIGNING_KEY_FILE so the JWKS key-ids never change across restarts.rollback-<name>; rebuild and bring it up; roll back by re-tagging if needed./healthz returns 200.BROKER_DOCS_DIR; it appears at /docs and is linked from the admin console.| Variable | What it does | Default |
|---|---|---|
BROKER_SIGNING_KEY_FILE | File-mounted ES256 token-signing key so the JWKS key-ids stay stable across restarts. | — (ephemeral) |
BROKER_DOCS_DIR | Directory of this documentation site, served read-only under /docs. | — |
DATABASE_URL | PostgreSQL connection string; without it all state is in-memory and lost on restart. | — (in-memory) |
GEOIP_DIR | Directory of GeoIP databases used for location and risk signals. | ./geoip |
# Stable JWKS + docs + geo signals
BROKER_SIGNING_KEY_FILE: /keys/broker_es256.pem # keep the kids stable across restarts
BROKER_DOCS_DIR: /docs-site # serve this documentation at /docs
GEOIP_DIR: /geoip # MaxMind DBs for geo rules# nginx — terminate TLS, forward to the broker (BROKER_BIND)
server {
listen 443 ssl http2;
server_name id.example.com;
ssl_certificate /etc/ssl/id.example.com.crt;
ssl_certificate_key /etc/ssl/id.example.com.key;
location / {
proxy_pass http://127.0.0.1:8080; # BROKER_BIND
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_http_version 1.1; # WebSocket upgrade (approvals)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
# Caddy — same thing in two lines (automatic TLS)
# id.example.com {
# reverse_proxy 127.0.0.1:8080
# }Copy-paste starting points for connecting common apps. Every URL below is a real
broker endpoint — swap id.example.com, slugs and secrets for your own.
| Endpoint | Path | What it's for |
|---|---|---|
| Discovery | /.well-known/openid-configuration | Point any OIDC app here and it auto-configures every endpoint below. |
| Authorization | /authorize | Where users are sent to sign in (authorization-code + PKCE). |
| Token | /token | Exchanges the code for tokens; also the client-credentials grant. |
| UserInfo | /userinfo | Returns the signed-in user's claims (subject, email, groups). |
| Public keys | /jwks | The keys apps use to verify token signatures (stable key-ids). |
| Logout | /logout | RP-initiated logout / end-session. |
| SAML metadata | /saml/<slug>/metadata | IdP metadata for a registered SAML app; SSO at <code>/saml/<slug>/sso</code>. |
Ask for openid profile email groups — the profile scope
already returns the user's group names in the groups claim, so most apps map
a Pramaan group → an app role with a single rule. Register every app and its exact
redirect URI under Applications in the admin console to get its client id and secret.
Point the app at Pramaan's discovery document and it auto-configures. Register the client under Applications to get its id and secret, then map your directory groups to app roles via the groups claim.
# Grafana — [auth.generic_oauth]
enabled = true
name = Pramaan
client_id = grafana
client_secret = <from the admin console>
scopes = openid profile email groups
auth_url = https://id.example.com/authorize
token_url = https://id.example.com/token
api_url = https://id.example.com/userinfo
# Discovery (any OIDC app can use this single URL):
# https://id.example.com/.well-known/openid-configuration
# Map a Pramaan group to a Grafana role:
role_attribute_path = contains(groups[*], 'grafana-admins') && 'Admin' || 'Viewer'For apps that speak SAML instead of OIDC. Register the service provider in the console with its Entity ID and ACS URL, then hand it Pramaan's IdP metadata. The NameID is the user's email.
# Give the app Pramaan's IdP metadata (per registered app slug):
# https://id.example.com/saml/<app-slug>/metadata
# Single sign-on URL (HTTP-Redirect / HTTP-POST):
# https://id.example.com/saml/<app-slug>/sso
#
# In the admin console (Applications -> SAML), register the SP:
# Entity ID (audience): https://app.example.com/saml/metadata
# ACS URL (consumer): https://app.example.com/saml/acs
# NameID format: emailAddress
# The broker needs a SAML signing key+cert to serve metadata:
BROKER_SAML_KEY_FILE: /keys/saml_signing.pem
BROKER_SAML_CERT_FILE: /keys/saml_signing_cert.pemSome older apps can only authenticate against a directory. Turn on Pramaan's LDAP server and give the app a read-only service bind — your Pramaan users then sign in to it unchanged.
# Enable the LDAP server on the broker:
BROKER_LDAP_BIND: 0.0.0.0:3389 # 636 for implicit LDAPS, 389 for StartTLS
BROKER_LDAP_BASE_DN: dc=ldap,dc=goauthentik,dc=io
BROKER_LDAP_SERVICE_BIND_DN: cn=ldapservice,ou=users,dc=ldap,dc=goauthentik,dc=io
BROKER_LDAP_SERVICE_PASSWORD: <set a strong secret>
#
# In the app, configure LDAP:
# Host / port: id.example.com:3389
# Bind DN: the service bind DN above
# User search base: ou=users,dc=ldap,dc=goauthentik,dc=io
# Username attribute: uid (or cn)Nextcloud's OpenID Connect login app auto-configures from the discovery URL. Register an OIDC client for it, then point the app at Pramaan.
# Settings → Administration → OpenID Connect (user_oidc app)
# Identifier (client id): nextcloud
# Client secret: <from the admin console>
# Discovery endpoint:
# https://id.example.com/.well-known/openid-configuration
# Scope: openid profile email
# Or via occ:
occ user_oidc:provider Pramaan \
--clientid=nextcloud --clientsecret=<secret> \
--discoveryuri=https://id.example.com/.well-known/openid-configuration \
--unique-uid=0 --mapping-uid=preferred_usernameGitLab federates via its generic OpenID Connect omniauth provider — one block in gitlab.rb with discovery on.
# /etc/gitlab/gitlab.rb
gitlab_rails['omniauth_enabled'] = true
gitlab_rails['omniauth_allow_single_sign_on'] = ['openid_connect']
gitlab_rails['omniauth_providers'] = [{
name: 'openid_connect', label: 'Pramaan',
args: {
name: 'openid_connect', scope: ['openid','profile','email'],
response_type: 'code', discovery: true,
issuer: 'https://id.example.com',
client_auth_method: 'basic',
uid_field: 'preferred_username',
client_options: {
identifier: 'gitlab', secret: '<from the admin console>',
redirect_uri: 'https://gitlab.example.com/users/auth/openid_connect/callback'
}
}
}]Put single sign-on in front of an app that has no OIDC of its own. nginx asks Pramaan to authorise each request via a sub-request; unauthenticated users are bounced to sign in. Set the cookie domain so one session covers every sibling host.
# Broker — enable forward-auth across *.example.com
BROKER_FORWARD_AUTH_COOKIE_DOMAIN: .example.com
# nginx — protect the app with an auth sub-request (no app changes)
location = /forward-auth/verify { internal; proxy_pass http://broker:8080; \
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Uri $request_uri; }
location /forward-auth/ { proxy_pass http://broker:8080; } # start · callback · sign_out
location / {
auth_request /forward-auth/verify;
error_page 401 = @signin;
proxy_pass http://app-upstream;
}
location @signin { return 302 /forward-auth/start?rd=$scheme://$host$request_uri; }Authenticate kubectl against your cluster with Pramaan identities and map groups to RBAC. The API server trusts the same discovery URL; users sign in with an OIDC helper.
# kube-apiserver flags (or the equivalent in your control-plane config)
--oidc-issuer-url=https://id.example.com
--oidc-client-id=kubernetes
--oidc-username-claim=email
--oidc-username-prefix=pramaan:
--oidc-groups-claim=groups
--oidc-groups-prefix=pramaan:
#
# Users sign in with kubelogin (kubectl oidc-login) against the same issuer;
# bind a Pramaan group to a ClusterRole:
# kubectl create clusterrolebinding pramaan-admins \
# --clusterrole=cluster-admin --group=pramaan:platform-adminsChoose the capabilities you want and copy a ready-to-paste docker-compose environment block. Everything is off until you switch it on — this assembles exactly what you choose. Swap the example hosts, key paths and secrets for your own.
Every environment variable the broker reads, parsed straight from the source so
it stays complete and current. Secrets can be supplied inline or via a *_FILE
path read once at startup. Use the search box to filter.
| Variable | Description | Default |
|---|---|---|
BROKER_ENRICH_CLIENT_IDSBROKER_FEDERATION_CLAIM_ENRICH | OIDC client_ids that receive enriched identity claims (groups/roles/…) even when the global federation claim-enrichment flag is off. For DIRECT Pramaan clients (e.g. the mobile companion) whose id_token/userinfo we want enriched without changing the authentik "Login with Pramaan" federation path, which stays gated by that global flag. Comma-separated | — |
BROKER_IDENTITY_SYNC_TICK_SECS | How often the broker checks whether any identity source is due for a scheduled directory sync. Only the tick: each source's own sync_interval_secs (0 = manual only) decides whether that source syncs at all and how often, so a broker with no source on a timer does nothing. 0 disables the scheduler entirely. | 60 |
BROKER_RS256_KEY_FILEBROKER_RS256_KEY_PEM | Adds an RSA key so clients that pin RS256 receive RS256-signed id_tokens. | — |
BROKER_SAML_CERT_FILEBROKER_SAML_CERT_PEM | X.509 certificate (PEM) matching Self::saml_key_pem, published in the IdP metadata and <ds:KeyInfo>. / | — |
BROKER_SAML_KEY_FILEBROKER_SAML_KEY_PEM | RSA assertion-signing key; with its cert it turns on the native SAML 2.0 IdP. | — |
BROKER_SAML_SIGN_METADATA | Sign the IdP metadata document with an enveloped XML-DSig signature (some strict SPs require signed metadata). Off by default — the unsigned descriptor is what almost every SP imports | false |
BROKER_SAML_SP_CLOCK_SKEW_SECS | How far an inbound SAML assertion's Conditions window may lie outside our own clock and still be accepted, in seconds. SAML's validity windows are absolute instants set by a machine we do not run, so two correctly-configured hosts a second apart would otherwise reject each other's assertions. The allowance is BOUNDED and small: it widens the window at BOTH ends, so a large value is also a large replay window for an assertion that has already expired. Values above saml::sp::acs::MAX_CLOCK_SKEW_SECS (600) are clamped to it, never honoured | 60 |
BROKER_SAML_SP_ENABLE | Host the SAML 2.0 Service Provider — Pramaan CONSUMING an assertion issued by somebody else's IdP, so an existing SAML IdP can authenticate a Pramaan sign-in. This is the opposite direction from Pramaan's own SAML IdP at /saml/{slug}/sso, which asserts identity to a downstream SP. Both can be on at once; they share a protocol and no code. Default off. When off the Assertion Consumer Service route is not registered at all, and /authorize/federated/{slug} — which is shared with OIDC federation and so is always registered — answers 404 for a source of kind saml, exactly as it does for a slug that does not exist. An endpoint that turns a posted document into a sign-in should exist only where a customer has asked for it. WHAT THIS SWITCH DOES AND DOES NOT GIVE YOU, stated here so that nobody meets it in production. Turning it on gives a complete SP-initiated sign-in, in two routes. GET /authorize/federated/{slug} STARTS one: for a source of kind saml it builds an AuthnRequest, records it as a single-use outstanding request, and redirects the browser to the SingleSignOnService the IdP's own metadata publishes, over the HTTP-Redirect binding. The same URL starts an OIDC sign-in for a source of kind oidc; the slug decides, and there is no second entry point. The Assertion Consumer Service at /saml/sp/acs then CONSUMES the assertion that comes back and matches its InResponseTo against that outstanding request, which is what binds the assertion to a sign-in this SP actually began. It then maps the assertion onto a local user — creating one owned by this source, or attaching to the existing user of that name — and establishes a browser session, which completes the sign-in. A deactivated user is refused at that last step, however good the assertion. The session always lands on /: the pending request carries no post-login destination, so a ?next= on the start URL is ignored on the SAML path, while it is honoured on the OIDC one. ONE REQUIREMENT ON THE IdP that is easy to miss and refuses every sign-in when unmet. Unless the source names a username_attribute, the username comes from the assertion's NameID, and this SP will only take an identity from a NameID whose format the IdP's metadata DECLARES and which is durable — persistent, emailAddress or unspecified. Metadata that declares no NameIDFormat at all, or declares the transient per-session pseudonym, is refused: nothing in it states that the value identifies the same person twice. Set username_attribute to name the attribute that carries identity instead. Five further limits are deliberate, each a decision rather than an oversight. A Response and its Assertion signed BOTH at once is refused: ADFS, Entra ID and a default Shibboleth each sign one of the two and are accepted, but a Shibboleth configured to sign both is not. Keycloak needs this setting changed before it will work at all: turning on "Sign assertions" there leaves "Sign documents" on as well, so it signs both and every sign-in is refused with multiple_signatures. Turn ONE of them off — prefer keeping the assertion signed, since the assertion is what carries the identity and stays signed if it is ever detached from its Response. Verified against Keycloak 26.7.4 end to end, which is the one entry in this list that has been observed rather than reasoned about. IdP-initiated SSO is not supported, because an unsolicited assertion carries no InResponseTo and so cannot be tied to a request this SP actually made, which is the only defence against a login-CSRF replay. Encrypted assertions inbound are not supported; the assertion must arrive in the clear inside TLS. The AuthnRequest is not signed, so an IdP configured with WantAuthnRequestsSigned rejects it at its own end — visibly, not silently. And the IdP metadata's own validUntil and cacheDuration are ignored: trust is the exact certificate bytes an administrator pasted, so an expired document admits no attacker, but on the day the IdP rotates its signing certificate an operator must paste the new metadata or every sign-in through that source stops | false |
BROKER_SCOPE_ENFORCE | Confines each request's scopes to the client's registered allow-list (openid always kept). | false |
BROKER_SLHDSA_SECRET_FILEBROKER_SLHDSA_SECRET_B64 | Post-quantum (FIPS 205) key that anchors the tamper-evident receipt chain head. | — |
| Variable | Description | Default |
|---|---|---|
BROKER_ACCOUNT_OIDC_CLIENT_ID | OIDC client_id the self-service portal signs in with (PKCE public client); read at runtime from /account/config.js | "account-portal" |
BROKER_ACCOUNT_UI_DIR | Directory of the end-user self-service portal, served under /account. | — |
BROKER_ADMIN_BIND | Binds the separate, gated admin API + console — never on the public socket. | — (disabled) |
BROKER_ADMIN_CONSOLE_HOST | Host the TENANT ADMIN CONSOLE is served at for the id tenant. The admin listener cannot resolve its tenant the way the public listener does. tenant::resolve::resolve_tenant reads the FIRST LABEL of the Host as a slug, and this console lives at admin.<tenant_base_domain> — admin is in RESERVED_SLUGS precisely so that no tenant can ever own it, so host-based resolution finds nothing and answers 404. Naming the host explicitly is what lets the console keep working while every request through it still carries a tenant. Empty when tenant_base_domain is empty (a misconfiguration, not a mode), which makes the mapping unmatchable and the console 404 rather than silently administering the wrong tenant | — |
BROKER_ADMIN_GROUP | Group a signed-in user must belong to for admin access. | pramaan-admins |
BROKER_ADMIN_OIDC_CLIENT_ID | OIDC client_id the admin console uses to sign in (PKCE public client). The SPA reads this at runtime from /config.js | "admin-console" |
BROKER_ADMIN_ORIGINS | Browser origins (scheme+host, with a port if not the default) allowed to call the platform issuer's /token cross-origin — the configured issuer, i.e. the id workspace's. Comma-separated. Honoured ONLY on the platform issuer's own host — never on a workspace's issuer (<slug>.<base>), which refuses these origins. A workspace's own console (https://admin.<slug>.<base>) needs no entry here: it is allowed on its own workspace's issuer automatically, and on no other. With this empty and no tenant base domain there is no CORS at all, i.e. same-origin only. EVERY console served on its own host belongs here, not just the admin one: each is a separate-origin OIDC client of this issuer, so each exchanges its authorization code cross-origin. Omitting one produces a uniquely misleading failure — /token answers 200 and MINTS the tokens, and the browser then refuses to let the page read the response for want of an Access-Control-Allow-Origin header. Sign-in appears to fail after the server has already succeeded, and the broker's own log records a successful token exchange | — |
BROKER_ADMIN_REQUIRE_SCOPE | Also requires the pramaan.admin scope on admin tokens. | false |
BROKER_ADMIN_TRUSTED_CIDRS | Source-IP allowlist (CIDRs, comma-separated) for the admin listener — a network layer on top of admin-group auth. Empty = allow any peer that can reach the (already private) listener | — |
BROKER_ADMIN_UI_DIR | Directory of the built admin console SPA; unset serves the API only. | — |
BROKER_DOCS_DIR | Directory of this documentation site, served read-only under /docs. | — |
| Variable | Description | Default |
|---|---|---|
BROKER_DESKTOP_ENROL | Lets a desktop client (no Play Integrity) enrol on its verified login alone — never the trusted tier. | false |
BROKER_PASSWORD_BREACH_API_URL | Optional k-anonymity breached-password range API consulted when a user sets a local password. Unset by default (§5.4.1): a third party in the credential-set path turns their outage into our onboarding outage, and the usual mitigation — fail open — silently disables the control. When set, the call has a hard 2-second timeout and fails CLOSED: a timeout refuses the password set. That is safe here and ONLY here, because the caller is a user choosing a password who can retry; it must never be wired into a verification path. This field is plumbing only as of Task 2: nothing in the crate reads it yet, and no HTTP call is made against it. policy::check runs only the local compiled-in blocklist (passwords::blocklist::contains) — that call happens strictly AFTER the local list, per the doc above, so a future consumer can only ever add refusals, never remove one the local list already produced. The registration task that wires the actual call is what makes the fail-closed timeout above true; until then this setting has no effect no matter what it is set to | — |
BROKER_PASSWORD_HASH_PARALLELISM | How many local-password (Argon2id) hashes may run at once, process-wide — worst-case transient memory is permits * 64 MiB. Defaults to half of the box's available CPUs, floored at 1; clamped to [1, 8] either way, so a bad env value can't blow that arithmetic out in either direction. This field is documentation and introspection only — it is not the live enforcement point. passwords::permits() is a process-wide OnceLock<Semaphore> initialised on first use, before any Config necessarily exists, so it reads the environment variable itself rather than taking a value from here; wiring it through Config/AppState would mean plumbing config into a OnceLock initializer for no behavioural gain. What this field buys instead: docs_gen walks Config::from_env's struct literal to build the published config reference, and this variable was previously read by a raw std::env::var nowhere near that struct — invisible to the generator despite routes.rs already telling operators to set it. Both this field and permits() compute their value through the SAME function, passwords::resolve_hash_parallelism, so the default and the clamp cannot drift between the two call sites; see that function's tests | — |
BROKER_PASSWORD_LOGIN | Offers username + password (live FreeIPA bind) as an alternate sign-in method. | false |
BROKER_RECOVERY_ENROL | Lets a single-use recovery code authorise enrolling a replacement device. | false |
| Variable | Description | Default |
|---|---|---|
BROKER_ADAPTIVE_STEPUP | Risk-based step-up: asks for MFA on impossible travel, bad reputation or an unfamiliar country. | false |
BROKER_AGENT_POSTURE_ENABLE | Enables the /agent/posture ingest endpoint the desktop client reports signals to. | false |
BROKER_ATTEST_ON_APPROVAL | Verifies a fresh Play Integrity token at approval time, not just at enrolment. | false |
BROKER_DEVICE_CA_CERT_FILEBROKER_DEVICE_CA_KEY_FILE | Device CA certificate + private key (PEM) for issuing short-lived client certs (CN = device_id) to desktop agents. Both unset means the issuance route 404s and no device CA exists. /_PEM + /_PEM. Secret; never logged | — |
BROKER_DEVICE_CERT_TTL_SECS | Validity (seconds) of an issued device client cert. Default 7 days — short enough that a decommissioned device loses edge trust quickly, long enough to avoid constant re-issuance | 7 * 24 * 60 * 60 |
BROKER_DEVICE_MTLS_ENABLE | Master switch for the mTLS edge binding: when on, the edge (forward_auth_verify + connector_edge_middleware) reads the proxy-forwarded verified client-cert CN, maps it to a device, and prefers that device's live agent posture over the session's. Off means the cert headers are ignored and behaviour is unchanged | false |
BROKER_DPOP_ENFORCE | Requires a valid device-bound DPoP proof on /token, /userinfo and /introspect. | false |
BROKER_DPOP_ENFORCE_API | Extends DPoP enforcement to the core /api endpoints (applications, ZTNA apps, devices, sessions) and /session/handoff; a device-bound token must present a matching proof there. Unbound tokens are exempt, so browser portals are unaffected. Flip only after the mobile and desktop clients that send /api proofs are rolled out. | false |
BROKER_KEY_ATTESTATION_ENABLE | Verifies Android Key Attestation chains so hardware-backed is proven, not self-asserted. | false |
BROKER_KEY_ATTESTATION_REQUIRE_STRONGBOX | Require StrongBox (a dedicated secure element), not just TEE, before a key counts as hardware-backed. Default false = TEE is sufficient | false |
BROKER_KEY_ATTESTATION_ROOTS | Trusted Google attestation root certs; without them nothing can be hardware-backed. | — (fail closed) |
BROKER_LIMITED_TIER_TTL_SECS | Caps session lifetime for non-trusted devices; 0 leaves standard TTLs. | 0 (off) |
BROKER_POSTURE_MAX_AGE_SECS | Age after which a posture snapshot is treated as tier unknown; 0 never expires it. | 0 (off) |
BROKER_POSTURE_SWEEP_SECS | Period of the background sweep that revokes sessions whose device is gone or degraded. | 0 (off) |
BROKER_POSTURE_UNKNOWN_ACTION | What fail-closed does when it fires — deny or step_up. | deny |
BROKER_POSTURE_UNKNOWN_FAIL_CLOSED | Turns an Allow into a Deny for unknown-posture devices on ZTNA / forward-auth. | false |
BROKER_REATTEST_ON_REFRESH | Re-runs posture + policy on every refresh-token grant (continuous authorisation). | false |
| Variable | Description | Default |
|---|---|---|
BROKER_CONNECTOR_BIND | Binds the gated listener that dark-app connectors dial into over pinned TLS. | — |
BROKER_CONNECTOR_CLIENT_CA_FILE | Optional client-cert CA (PEM) for the connector listener: when set, a connector must present a client certificate that chains to this CA (verified in-process by rustls) — defense-in-depth ON TOP of the cert pin + registration key, so a leaked registration key alone can't connect. Unset = no client auth, behaviour unchanged. /_PEM | — |
BROKER_CONNECTOR_ENABLE | Installs the edge that reverse-proxies browser requests to registered private web apps. | false |
BROKER_CONNECTOR_ENROLLED_ONLY | Accepts only per-connector enrolled tokens, refusing the shared registration-key bootstrap. | false |
BROKER_CONNECTOR_KEY_FILEBROKER_CONNECTOR_CERT_FILE | TLS server key + cert (PEM) for the connector listener; the connector pins this cert. /_PEM + /_PEM | — |
BROKER_CONNECTOR_PUBLIC_ADDRCONNECTOR_BROKER_ADDR | Public host:port a remote connector dials to reach this broker's connector listener (). Surfaced to the admin console so the connector-deployment guide can pre-fill . When unset the console falls back to the issuer host + :9445 | — |
BROKER_CONNECTOR_REGISTRATION_KEY | Shared registration key a connector must present in its HELLO (constant-time checked). Required for the listener to accept any connector. Secret; never logged. (file via _FILE, else inline) | — |
BROKER_FORWARD_AUTH_COOKIE_DOMAIN | Cookie Domain for forward-auth sessions (#155), e.g. .pramaanid.cloud. The forward-auth session cookie is set with this domain so a reverse proxy in front of any sibling host under it (grafana.pramaanid.cloud, …) carries it on the auth_request subrequest. When unset, the WHOLE forward-auth feature is disabled (routes 404) — it cannot work cross-host without a parent-domain cookie. The broker's own issuer host MUST be a member of this domain. What your reverse proxy must send. Two hostnames matter on the subrequest and swapping them is the usual misconfiguration: X-Forwarded-Host names the APPLICATION being protected (the host you registered as a forward-auth application), while Host names the WORKSPACE whose users and groups decide the request. So a proxy protecting an application belonging to workspace acme addresses the subrequest to https://acme.<tenant base domain>/forward-auth/verify, and sends an unauthenticated (401) visitor to https://acme.<tenant base domain>/forward-auth/start?rd=.... A Host that names no workspace resolves to the default workspace, which keeps single-workspace deployments working unchanged. If an application belongs to a workspace but the proxy leaves Host as the application's own hostname, the request is decided against the wrong workspace, the application is not found there, and every user is refused — so set Host explicitly. Pramaan decides these requests; it does not serve them. A forward-auth application records a hostname and the groups required to reach it and names no upstream, so the application itself stays behind your own proxy | — |
BROKER_RAC_GUACD_ADDR | RAC (Remote Access) guacd address, e.g. guacd:4822. When set, the RAC WebSocket tunnel (/api/rac/{id}/tunnel) is enabled and proxies to this guacd. Unset = RAC connect disabled (registry/admin still work). An endpoint's stored connection credentials are write-only: the console displays a mask and sends that mask back to keep the stored value. A save that submits the mask while ALSO changing the endpoint's protocol, host or port is refused, because the stored credential would otherwise be relayed by guacd to a destination the admin has just changed; re-enter the credential in the same save to move an endpoint | — |
BROKER_TCP_TUNNEL_BIND | Binds the gated raw-TCP ingress (SSH / RDP / Postgres over ZTNA) for the client forwarder. | — |
BROKER_TCP_TUNNEL_REQUIRE_SCOPE | Requires the tunnel token to carry the pramaan.tunnel scope. | false |
BROKER_ZTNA_CONNECTOR_STALE_SECS | Health-aware routing: a connector idle longer than this (seconds) is demoted below healthy peers of the same route specificity/metric, kept only as failover. | 90 |
BROKER_ZTNA_L3_ENABLE | Enables always-on L3 ingress: an app-less tunnel that carries only a destination is auto-resolved to a segment. | false |
BROKER_ZTNA_TRUST_ADVERTISED_ROUTES | Lets connector-declared routes be used for routing fleet-wide (otherwise only admin static routes, or a per-site opt-in). Learned routes only fill gaps and never override a static route. | false |
| Variable | Description | Default |
|---|---|---|
BROKER_DLP_ENABLE | Master gate for the inline content-scan (/dlp/scan) endpoint. | false |
BROKER_DLP_KEY | Shared key the calling scanner must present (constant-time) on /dlp/scan. Secret; never logged. (file via _FILE, else inline) | — |
BROKER_SWG_ALLOW_NO_SNI | Permit splicing a CONNECT tunnel whose ClientHello carries NO SNI. Default FALSE — a no-SNI ClientHello lets a client reach a policy-denied co-hosted origin on the allowed IP via the inner Host header (domainless fronting), evading category/allowlist policy and HTTPS-DLP. Legitimate TLS 1.2/1.3 clients always send SNI, so failing closed here has no real-world cost | false |
BROKER_SWG_ALLOW_PUBLIC_BIND | Permit binding the proxy listener to a public (non-loopback/private) address. Default false — a guard against accidentally exposing the proxy to the internet | false |
BROKER_SWG_ANALYTICS_ENABLE | Record gateway traffic metrics, INCLUDING per-user destination history (swg.user_destinations). Default off: a default build accumulates no browsing history at all, matching BROKER_CVE_SYNC_ENABLE's posture of keeping a whole capability inert until an admin opts in. Retention follows the standard rollup schedule, up to 13 months at daily granularity | false |
BROKER_SWG_CATEGORY_DIR | Directory of URL/domain category feeds that drive category-based policy. | — |
BROKER_SWG_CATEGORY_RELOAD_SECS | How often (seconds) to reload the category feeds. Default 3600 | 3600 |
BROKER_SWG_CONNECT_PORTS | Comma-separated list of ports the proxy will CONNECT to. Default "443" (HTTPS only) | "443" |
BROKER_SWG_DENY_NETS | Extra CIDRs to DENY on top of the built-in non-globally-routable set, so an operator can carve out additional internal ranges the proxy must never egress to. Comma-separated | — |
BROKER_SWG_DLP_KEYWORDS_FILE | File of DLP keywords/patterns (one per line) for inline content scanning. Unset means no keyword DLP | — |
BROKER_SWG_DNS_BINDBROKER_SWG_ENABLE | Binds the gated private-DNS filter (categorise → forward or sinkhole). | — |
BROKER_SWG_DNS_SINKHOLE_IP | IPv4 address returned for BLOCKED A queries (a sinkhole / black-hole). Default 0.0.0.0 | "0.0.0.0" |
BROKER_SWG_DNS_UPSTREAM | Upstream resolver the DNS filter forwards ALLOWED queries to (host:port). Default 1.1.1.1:53 | "1.1.1.1:53" |
BROKER_SWG_ENABLE | Master gate for the Secure Web Gateway policy endpoint and its proxy / DNS listeners. | false |
BROKER_SWG_FAIL_CLOSED | Makes SWG/DLP verdicts deny on store outage, rule error or no match. | true |
BROKER_SWG_FILETYPE_BLOCK | Magic-byte-sniffed file categories to block, e.g. executable, archive, script. | — (none) |
BROKER_SWG_FILETYPE_BLOCK_MISMATCH | Blocks a declared Content-Type that disagrees with the sniffed true type. | false |
BROKER_SWG_INSPECT_MAX_BODY | Max response body buffered for inline DLP / sandbox inspection (bytes). | 10485760 |
BROKER_SWG_KEY | Shared key the calling proxy must present (constant-time checked) on /swg/authorize — the proxy is trusted infra, like the ZTNA connector. Secret; never logged. (file via _FILE, else inline) | — |
BROKER_SWG_PROXY_BIND | Binds the inline HTTP forward-proxy data plane (needs SWG enabled). | — |
BROKER_SWG_REQUIRE_AUTH | Require an authenticated identity for proxy egress. Secure default TRUE; set it to false only for a transitional deployment | true |
BROKER_SWG_SANDBOX_TIMEOUT_MS | Hard end-to-end timeout for an inline sandbox submission. A sandbox that is slow/unreachable is best-effort (does not block). Default 8000 ms | 8000 |
BROKER_SWG_SANDBOX_URL | On-prem detonation sandbox to submit downloads to; a malicious verdict blocks. | — |
BROKER_SWG_TLS_INSPECT | Enables selective HTTPS inspection — only for Inspect-rule traffic in listed categories. | false |
BROKER_SWG_TLS_INSPECT_CATEGORIES | The only categories ever decrypted; empty means inspect nothing even with the flag on. | — (none) |
BROKER_SWG_TLS_INSPECT_CA_CERT_FILE | The inspection CA cert + key PEM (a SEPARATE CA from the device-auth CA; managed clients must trust this root for inspection to be transparent). Both required for inspection to arm; if the flag is on but either is missing/unloadable the proxy refuses to start (fail-safe). Secret; never logged. /_PEM + _CA_KEY_FILE/_PEM | — |
| Variable | Description | Default |
|---|---|---|
BROKER_DEM_ENABLE | Enables the /dem ingest and admin DEM API; off means the routes 404 and nothing is recorded. | false |
BROKER_DEM_SYNTHETIC_SECS | Interval for the broker's synthetic prober; only probes monitors flagged synthetic. | 0 (off) |
| Variable | Description | Default |
|---|---|---|
BROKER_AGENTS_ENABLE | Enables the agent registry and the client_credentials grant for AI-agent identities. | false |
BROKER_AGENTS_PROVENANCE | Adds a signed ES256 provenance receipt to every agent token-mint and tool authorisation. | false |
BROKER_AGENTS_REQUIRE_ENROLLED_DEVICE | Requires an agent's DPoP key to belong to an owner-enrolled device before minting a token. | true |
BROKER_MCP_ENABLE | Enables the /mcp/authorize per-tool Allow/Deny broker. | false |
| Variable | Description | Default |
|---|---|---|
BROKER_ADVISOR_ENABLE | Master switch for the Posture Advisor (INCREMENT J). The advisor runs the deterministic collectors, then asks the copilot SLM to PRIORITIZE + draft remediations, every draft re-verified/re-proved before it can be shown or accepted. REQUIRES copilot_enable too — the /admin/advisor/* routes are mounted only when BOTH are on, so a default build has no advisor surface at all. Off | false |
BROKER_ADVISOR_STALE_DAYS | Days without a policy-engine hit before an enabled access rule is reported stale by the advisor's collector. Default 30 | 30 |
BROKER_COPILOT_ENABLE | Master switch for the on-prem Policy Copilot (admin listener only). | false |
BROKER_COPILOT_MIN_CONFIDENCE | Confidence floor below which the copilot abstains instead of guessing. | 0.0 (off) |
BROKER_COPILOT_MODEL | Model id sent to the local SLM server. | qwen3-1.7b-instruct-q4_k_m |
BROKER_COPILOT_MODEL_SHA256 | Model-weights hash recorded verbatim in a committed rule's reproducibility receipt. | — |
BROKER_COPILOT_REFINE_MAX | Max iterations for the self-refining compile loop (POST /admin/copilot/refine). Clamped 0..=5 at load; 0 DISABLES refinement (the route returns 400 even with copilot enabled) — an inert switch inside the already-default-OFF copilot. Default 3 | 3 |
BROKER_COPILOT_REFINE_WALL_MS | Wall-clock budget (milliseconds) for one WHOLE refine run, enforced independently of the per-call copilot_timeout_ms so a slow model cannot hold the admin listener for max × timeout. Default 90000 | 90_000 |
BROKER_COPILOT_TIMEOUT_MS | Hard end-to-end timeout (milliseconds) for one SLM completion. Default 30000 | 30_000 |
BROKER_COPILOT_URL | Base URL of the LOCAL SLM server — must be loopback / private / localhost or the broker refuses to start. | — |
| Variable | Description | Default |
|---|---|---|
BROKER_CAEP_ENABLE | Publishes the SSF configuration and pushes signed CAEP session-revoked events to receivers. | false |
BROKER_SSF_REGISTRATION_KEY | Shared registration key for the receiver-facing SSF Stream Management API (/ssf/streams etc.): a relying party presents it as Authorization: Bearer <key> to self-register/manage its own push stream (constant-time checked). Unset means that API is disabled (404) and streams are admin-managed only. Secret; never logged. (file via _FILE) | — |
| Variable | Description | Default |
|---|---|---|
BROKER_LDAP_BASE_DN | The directory base DN exposed to LDAP clients. Defaults to authentik's dc=ldap,dc=goauthentik,dc=io so an existing app (e.g. GitLab) can cut over with no config change | "dc=ldap,dc=goauthentik,dc=io" |
BROKER_LDAP_BIND | Binds the LDAP-server listener so legacy apps can authenticate against Pramaan. | — (disabled) |
BROKER_LDAP_KEY_FILEBROKER_LDAP_CERT_FILE | Implicit-TLS (ldaps) for the LDAP listener: server key + cert (PEM). When BOTH are set the listener speaks TLS from the first byte (point clients at ldaps://host:636); unset = plaintext LDAP. /_PEM + /_PEM | — |
BROKER_LDAP_SERVICE_BIND_DN | Service-account bind DN that apps use to search the directory (read-only). Defaults to cn=ldapservice,ou=users,<base> | — |
BROKER_LDAP_SERVICE_PASSWORD | Password for the service-account bind DN (value, read from ). Unset = no service account (search needs a user bind). Never stored in the DB | — |
BROKER_LDAP_STARTTLS | StartTLS mode for the LDAP listener: when true (and a key+cert are set), the listener stays PLAINTEXT and upgrades to TLS on a StartTLS extended request (RFC 4511 — point clients at ldap://host:389 + StartTLS), instead of implicit ldaps. Mutually exclusive with ldaps on the same port | false |
BROKER_LDAP_TRUSTED_CIDRS | Source-IP allowlist (CIDRs) for the LDAP listener — defense in depth on top of bind auth. Empty = allow any peer that reaches the listener | — |
| Variable | Description | Default |
|---|---|---|
BROKER_APP_INSTALL_URL | Public page an invitee is sent to in order to INSTALL the Pramaan app, rendered on the invitation ceremony as a QR code. Unset means the ceremony says the app is not available from this deployment, which is the honest answer rather than a QR pointing nowhere. It must be a page, not the APK itself. The invitee scans this from a phone that has no Pramaan app on it, so whatever answers has to explain what the file is, publish the digest and signer so the download can be checked, and walk the user through the install-from-browser warning Android shows for anything not from the Play Store. A direct link to a binary lands them in a download manager with none of that. It is rendered into GET /invite, which is byte-identical for every caller on a host (§6.2). That holds only because this value is per DEPLOYMENT and never per invitation: a URL that varied by invitee would turn the page into an oracle for the token it must not be able to read. Off-origin by design, and the page links rather than fetches — the ceremony serves default-src 'self', so a cross-origin image or script would be blocked, but a link and a QR (drawn locally, from this string) are not requests | — |
BROKER_CVE_SYNC_ENABLE | Periodically mirror OSV.dev (Debian/Alpine) + CISA KEV + a curated macOS/Windows seed, and match reported packages against them. Default off: the whole feature -- including the KEV hard tier-drop gate -- is inert until an admin opts in | false |
BROKER_DEVICE_REQUIRE_SCOPE | Require pramaan.device on the access token that authenticates the DEVICE enrolment and management surface. Default false, and it has to be: today's Flutter app requests openid profile email offline_access and not this, so turning it on before shipping an app that asks for it breaks every device flow at once. Ship the app, confirm real tokens carry the scope, then set this. It is reversible: unsetting it restores today's behaviour immediately | false |
BROKER_ENROLL_GRANT_TTL_SECS | Seconds a scoped device-enrollment grant stays valid (design §6.4.3), minted by POST /api/invite/app-grant and spent once at POST /enroll. default 900 (15 minutes). The window is measured from the moment the invitee actually chooses the app and is shown the QR, not from redemption — the grant is minted lazily, so someone who reads the page slowly gets the full 15 minutes rather than whatever is left of the ceremony. 15 minutes is an install-the-app-and-scan window; it is not a session, and there is no refresh concept, so a longer value buys nothing but exposure. It is a CEILING, not a promise. §6.4.5 caps the whole ceremony at 45 minutes from redemption, and the grant is capped 5 minutes below that wall so the ceremony ticket always outlives the grant it authorised — so a mint late in a ceremony gets less than this value, and a mint with under 5 minutes of ceremony left is refused outright rather than handed a grant that would outlive its own status endpoint. Raising this setting past 40 minutes therefore changes nothing at all. Not clamped at the read site, exactly as the invitation TTL above is not: an operator who sets 0 gets a grant that has already expired by the time /enroll looks at it, which is the fail-closed direction, and silently repairing an explicit value is the worse of the two failures. A fixture built with ..Default::default() gets 0 for the same reason and must set this field to mint a usable grant | 15 * 60 |
BROKER_INVITE_RETENTION_SECS | How long an EXPIRED invitation row is kept after expires_at, in seconds, before the hourly reaper deletes it. Default 30 days. Expiry is a state, not a deletion (design §4.6). The credential is dead the instant expires_at passes — redemption refuses on the same non-strict boundary the reaper claims — but the ROW outlives it by this window so the console can still answer "was Alice ever invited, and what became of it", and so the InvitationExpired audit event has a row to point at. Deleting on expiry would make an invitation nobody opened indistinguishable from one never issued. 30 days is the operational answer to "did this arrive?", asked weeks later. It is not an audit-retention setting: the audit events outlive this window and are bounded separately. Not clamped at the read site, exactly as invite_ttl_secs and enroll_grant_ttl_secs above are not. 0 means an expired invitation is purged by the first sweep that sees it — a real choice for an operator who wants the row gone, and one that costs nothing in audit terms because the reaper writes InvitationExpired BEFORE it purges (reaper::sweep_once, and the ordering is what that function exists to make structural). A fixture built with ..Default::default() gets 0 for the same reason invite_ttl_secs does | 30 * 24 * 60 * 60 |
BROKER_INVITE_TTL_SECS | How long a self-registration invitation link stays redeemable, in seconds. Default 72 hours. The number is otherwise arbitrary, so the reasoning is recorded here (design §4.3): 72 hours survives a weekend, plus greylisting and a forwarded mailbox, so a link sent on Friday afternoon still works on Monday morning. 24 hours does not, and would generate re-send traffic every weekend for no security gain. 7 days goes the other way — it leaves a redeemable artefact sitting in a mailbox for a week, which is the standing-secret property the invite flow exists to avoid. 72 hours is where the operational win stops and the exposure starts growing. The link is not a 72-hour capability to enrol a factor: opening it exchanges it at once for a short-lived ceremony ticket, which is what lets the TTL be measured in days at all | 72 * 60 * 60 |
BROKER_L3_FULLTUNNEL_ENABLE | Safety interlock for the full-tunnel (catch-all 0.0.0.0/0) datapath. A TcpApp whose allowlist contains a default route (/0 or ::/0) is the one shape that turns an ordinary ZTNA app into an internet egress, so it is only admissible when THIS is on — off (the default) treats such an app as unroutable and NACKs it, exactly as before the feature existed. Turning it on still does not open a relay by itself: the catch-all app is additionally gated by group visibility, a clean access-rule Allow (ZTNA is default-deny), and the egress connector's own allowlist | false |
BROKER_METRICS_FLUSH_SECS | Seconds between metric aggregator flushes. Counters accumulate in process and are written as deltas on each flush, so this is also the most data a crash can lose. Must stay well below the 5-minute fine bucket width. Default 30 | 30 |
BROKER_METRICS_TOP_K | How many distinct values of an unbounded dimension (a destination host) are kept per group per flush before the remainder is folded into a single __other__ row. Totals stay exact either way; only the breakdown is capped. Default 200 | 200 |
BROKER_PLATFORM_ADMINS | Usernames holding platform administrator authority in tenant id -- the surface that can create, suspend and destroy any customer workspace. Comma-separated; entries are trimmed and matched case-insensitively. Empty (the default) means nobody, and the platform console is inert. Granting therefore requires access to the deployment's configuration, which is the boundary this console exists to draw -- no API can write it. Revoking does not need a restart: a listed subject whose account is deactivated is refused, as is one with no account in tenant id at all. Matching is by username, so a username deleted and recreated inherits the authority. That is acceptable for an operator-sized list, and is stated here rather than left to be discovered | — |
BROKER_PLATFORM_CONSOLE_HOST | Host of the platform console — the only surface that may read the tenant table. A distinct ORIGIN, not a path prefix, so it inherits cookie isolation rather than relying on a path check. platform is in crate::tenant::RESERVED_SLUGS, so no tenant can ever be issued it. Empty when tenant_base_domain is empty (a misconfiguration, not a mode): the platform console then matches no host and is unreachable, which is the correct fail-closed behaviour for the surface that can create and destroy tenants | — |
BROKER_PLATFORM_UI_DIR | Directory of the built platform console SPA, served on the platform console origin only. Unset serves the platform API and no HTML there, which is the default: a console appears because an operator deployed one. It is a separate build from the admin console and separately configured, because the two origins are separately gated - the platform origin deliberately serves none of the tenant console, so pointing this at the admin SPA would put tenant-console code on the one surface that can destroy a workspace | — |
BROKER_SCIM_MAX_PAGE_SIZE | Largest count an inbound SCIM list request may ask for. A provider that asks for more is served this many and told so via itemsPerPage, which is the spec's behaviour and stops one request from reading a whole directory into memory | DEFAULT_SCIM_MAX_PAGE_SIZE |
BROKER_SCIM_SERVER_ENABLE | Host the inbound SCIM 2.0 service provider at /scim/v2, so an external IdP (Entra, Okta) can push users and groups INTO Pramaan on its own schedule. This is the opposite direction from the SCIM targets configured under /admin/scim-targets, which push Pramaan's users OUT to a downstream provider. Both can be on at once; they share a protocol and no code. Group membership follows the provider exactly. Okta's custom-app integration replaces a group's whole member list with PUT; Okta and Entra PATCH members in and out, including by a members[value eq "…"] filter. A replace makes the group's provider-sourced membership exactly the list sent — anyone absent is removed — while a membership an administrator granted locally in Pramaan is left in place. A request that renames a group is refused, because a group's id is its name. Request bodies up to 2 MiB are accepted, about 19,000 members in one full-list push. Default off, and when off the routes are not registered at all — an internet-reachable write surface should exist only where a customer has asked for it | false |
BROKER_SECRET_KEY | Whether the 32-byte envelope-encryption key is set. Generate one with openssl rand -base64 32; base64 or hex is accepted. This key encrypts secrets the broker stores at rest, each bound to the exact tenant, record and field it was sealed for so a ciphertext lifted from a backup cannot be replayed into another row: an identity source's LDAP bind password, an outbound SCIM target's client secret and service account JSON, a remote-access endpoint's credential, since #125 every TOTP shared secret, and every device offline-approval secret — the HMAC key a device's offline sign-in codes are minted from. TOTP makes this key load-bearing for SIGN-IN, which it was not before. The earlier users of the key all fail soft: an unopenable LDAP bind password breaks one directory sync. An unopenable TOTP secret fails a second factor, so the affected user cannot sign in at all. Three consequences follow, and an operator has to know all three. First, a broker that holds TOTP credentials — or devices with offline approval enabled — and has no key refuses to start, naming this variable. That is deliberate: the alternative is locking users out one sign-in at a time with nothing in the log to say why. Second, rotating this key without re-sealing invalidates every stored TOTP secret. Startup opens one row as a canary and refuses to boot if the key does not fit, so a bad rotation is caught at deploy time rather than by users. To rotate, re-seal the rows under the new key first. Third, restoring a database backup requires the key that was current when that backup was taken. Back the key up separately from the database. That separation is the whole point of envelope encryption, and it means a database dump alone can no longer restore a working system. This field is documentation and introspection only — the same pattern as password_hash_parallelism above. secretbox::key_from_env reads the variable directly at each use, because the key is needed in contexts that hold no Config (the store's own startup migration, and the pramaan_seed_totp bin). What the field buys is publication: docs_gen walks this struct literal to build the config reference, so a variable read only by a raw std::env::var is invisible to operators. Only the BOOLEAN is exposed — never the key itself | — |
BROKER_SESSION_WEBAUTHN_REPROOF_SECS | Seconds a WebAuthn session binding (#27) stays proved before the session stops resolving and the browser is bounced through a fresh assertion. 0 = no clock: a WebAuthn-bound session is not re-proved on a timer, but still dies with the credential it was bound to. Set this only with the UX in mind — the bounce is a real re-authentication, not a silent refresh | 0 |
BROKER_SMTP_FROM | Envelope sender and From: header, e.g. Pramaan ID <no-reply@example.com> | — |
BROKER_SMTP_HOST | Submission host invitation mail is sent through, e.g. mail.example.com. Unset means no SMTP transport; the broker falls back to the generic OTP_EMAIL_* webhook if one is configured, and otherwise refuses to issue invitations at all. Setting this makes the other BROKER_SMTP_* variables mandatory. A host with no username, password or from-address is a misconfiguration, not an unconfigured deployment, and the broker refuses to start a mail transport rather than quietly falling back to the webhook — a silent downgrade is invisible until an invitee says the mail never arrived. This is also the name the server certificate must be valid for: it is the SNI/verification domain, so an IP address or a CNAME the cert does not cover is a handshake failure | — |
BROKER_SMTP_PASSWORD_FILEBROKER_SMTP_PASSWORD | Its password. (preferred; a file the container mounts) or inline. Secret; never logged and never carried in an error. Use an alphanumeric-only value. A # in an env file is read as a comment and silently truncates the value, and a truncated password produces an auth failure that looks like a wrong credential rather than a parsing bug. This estate has been bitten by it already | — |
BROKER_SMTP_PORT | Submission port. default 465. 465 (implicit TLS) is the default on purpose (§8.2): the session is encrypted from the first byte, so there is no cleartext phase for an on-path attacker to strip. Port 587 selects STARTTLS instead — and in the mode that fails when the server does not advertise the upgrade, never one that continues in the clear. Any other port is treated as implicit TLS. There is no setting that disables TLS or certificate verification, by design | 465 |
BROKER_SMTP_REPLY_TO | Optional Reply-To:. Unset means replies go to the unattended submission mailbox, which is rarely what an invitee needs | — |
BROKER_SMTP_USERNAME | Submission account. A dedicated submission-only account (§8.3) — not a human mailbox and not the mail server's recovery admin — so that a compromise of the broker cannot read mail | — |
BROKER_SSH_ALLOW_UNVERIFIED_HOST_KEY | Let the SSH bastion open a session to a host whose key it CANNOT verify. Default false, and false is the safe direction. The bastion used to accept ANY host key unconditionally — a knowing deferral, recorded in a TODO and in this estate's compose file, but a real exposure: an attacker who answers the TCP connection reads every keystroke of an interactive shell and can inject commands, while the audit log records an ordinary successful session. With ssh_host_ca_key_file set, the bastion requires a host CERTIFICATE signed by that CA and this setting is irrelevant. Without it, the bastion REFUSES — unless this is true, which keeps an operator who has not issued host certificates yet working while making the exposure explicit and logged on every session rather than silent. It is a transition switch, not a configuration: the destination is a host CA, and every session opened under this flag writes a warning saying the connection is not protected against a machine-in-the-middle | false |
BROKER_TENANT_BASE_DOMAIN | Base domain new (non-id) tenants live under, e.g. pramaanid.cloud — a tenant with slug acme is reachable ONLY at acme.pramaanid.cloud. This is what tenant::resolve::resolve_tenant checks a request's full Host against (not just its first label) before trusting it: without this, a Host: acme.attacker.example would resolve to tenant acme and the discovery document would advertise attacker.example as the issuer. Read from ; defaults to issuer's host with its first label dropped, so the existing deployment (issuer https://id.pramaanid.cloud) needs no new configuration to keep working. It also decides where a workspace's APPLICATIONS are published. A template seeds each application under the workspace's own domain, as <application>.<slug>.<this domain> — so an acme workspace seeded from the demo template publishes its operations console at ops.acme.pramaanid.cloud. That is what lets two workspaces run the same template: a hostname routes to exactly one application across the whole deployment, so applications named on a shared domain could be created once and never again. Certificates for these names are obtained on demand and are authorised by the same endpoint that authorises a workspace's own hostname. Leaving this unset stops a workspace being seeded with applications at all: they would be given hostnames that can never resolve, so seeding them would create applications guaranteed to be unreachable, and the request is refused instead. Each workspace is administered at https://admin.<workspace>.<this domain>, signing in against its own issuer | — |
BROKER_TENANT_KEY_KEK | Base64 32-byte key-encryption key wrapping every tenant's private signing key and device CA key at rest. Unset = tenant provisioning is refused (fail closed); it never falls back to storing plaintext. Rotating it requires re-wrapping every stored key, so it is read once at start | — |
BROKER_TLS_ASK_BIND | Bind address for the on-demand TLS ask endpoint (host:port, e.g. 0.0.0.0:9447). Unset means the listener is not started and no certificate authorisation is answered. Do not use 9445 here or in a deployment: that is the connector listener's own port, which every ZTNA connector dials. A tenant is reachable at https://<slug>.<tenant_base_domain>, so it needs a certificate for that name. Rather than a wildcard certificate, point a reverse proxy's on-demand TLS ask at this endpoint: it answers 200 only for a host that an active tenant actually owns, so a certificate is obtained on the first request to a real tenant and never for anything else. Provisioning a tenant then needs no certificate step. Bind it on a private interface only. The endpoint is unauthenticated and its answer reveals whether a workspace exists, which the public resolver deliberately withholds. Reachable by the proxy, never published. Without an ask, a catch-all on-demand site will request a certificate for ANY name pointed at this host, which exhausts the CA's rate limits and stops real issuance | — |