प्रमाण · Pramaan ID
Pramaan
Sovereign Zero-Trust · On-premises

Run identity and access
on your own terms

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.

3-in-1IdP · SASE · token vendor, unified
11Device-posture signals, live
0Data leaves your network
136Settings, all documented

Architecture

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.

Endpoints Pramaan Broker · single control plane Your network Zero-Trust broker Postgres (state) · file-mounted signing key (stable JWKS) IdP · OIDC / SAML / SCIM Tokens · device-bound Policy · Zero-Trust SWG · DNS / TLS / files DEM · experience Transparency · log Agents · AI identity Ingress · per-app + L3 Browser · SPA Desktop client Mobile app Connectorsper site · VRF-aware Private appsTCP · web · dark DirectoryLDAP / FreeIPA sync HTTPS · sign-in TLS tunnel reach sync Endpoints Browser Desktop Mobile HTTPS · sign-in Pramaan Broker · one control plane Zero-Trust broker IdP · OIDC / SAML Tokens · device-bound Policy · Zero-Trust SWG · DNS / TLS DEM · experience Transparency · log Agents · AI identity Ingress · per-app + L3 Postgres · file-mounted signing key TLS tunnel Connectorsper site · VRF-aware reach Private appsTCP · web · dark DirectoryLDAP / FreeIPA sync sync
  • Authorised data flow · mutually authenticated TLS
  • Control / directory sync
  1. Endpoints connect inBrowsers, the desktop client and the mobile app all reach the one broker over HTTPS — nothing else is trusted by where it sits on the network.
  2. One control plane decidesIdentity, tokens, policy, the web gateway, monitoring and the transparency log live in a single service backed by Postgres and a stable signing key.
  3. It reaches your networkOutbound, per-site connectors bridge to private apps and your directory — so app networks open no inbound ports.

How a request flows

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.

1 Authenticate Sign in with OIDC/PKCEor SAML. Passkey, pushor one-tap QR as needed. 2 Bind the device The token locks to asilent hardware key; thedevice reports its health. 3 Decide Policy scores identity +posture + context intoallow, step-up or deny. 4 Reach Approved traffic tunnelsout via the app's siteconnector — no open ports. 5 Prove The decision is appendedto a signed, verifiabletransparency log.

How access is decided

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.

Signals Policy engine Decision · fail-closed Identity & scopes Device posture Context & risk Zero-Trustdecision ✓ Allow ↑ Step-up (MFA) ✕ Deny

See it in action

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:

9:41 Pramaan
Approve sign-in?
RequestingChrome · Windows
WhereNew Delhi, IN
● Normal risk
Tap the number shown in your browser
Match glyphs · ▲ ● ◆
Not you? Deny & report

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.

Pramaan · Device
Device health
Trusted tier
Disk encryption
Firewall
Screen lock
OS updates
EDR / antivirus
Secure Boot!
Always-on accessProtected · every app

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.

Pramaan · Digital experience
pramaan-sso96
Avail 99.8%p95 142 msloss 0%
inventory78
Avail 98.9%p95 380 msloss 1.2%
Worst-experiencing user · r.sharma · score 61 — triage first

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.

Core concepts

The ideas the rest of the docs build on — each is a capability you can enable independently.

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.

Connector & Site

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 tunnel vs always-on

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.

Secure Web Gateway

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.

Experience monitoring

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.

Device-bound tokens

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.

Device posture & tiers

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.

Transparency log

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.

Identity for AI agents

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.

Deploy & prerequisites

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.

What you need

  • Container runtimeDocker Engine 24+ and the Compose plugin. The broker ships as one image; a single docker compose up -d broker starts it.
  • PostgresAn external PostgreSQL 14+ reachable via DATABASE_URL. State (users, clients, sessions, monitors, log) lives here; the broker runs its own migrations at startup.
  • TLS terminationA reverse proxy (nginx / Caddy) terminating HTTPS in front of the broker's HTTP listener, forwarding to BROKER_BIND. The public URL is BROKER_ISSUER.
  • Signing keyA file-mounted ES256 signing key (BROKER_SIGNING_KEY_FILE) so the JWKS key-ids stay stable across restarts — federation depends on it.
  • Connectors (for ZTNA)One lightweight connector per network segment, registration-key gated, dialing out to the connector listener — no inbound ports on the app networks.

Bring it up

  1. Provision Postgres and put its URL in DATABASE_URL.
  2. Generate a signing key and mount it at BROKER_SIGNING_KEY_FILE; set BROKER_ISSUER to your public HTTPS URL.
  3. Start the broker: docker compose up -d broker. It migrates the database and serves the IdP, admin and account UIs.
  4. Put a reverse proxy in front terminating TLS and forwarding to BROKER_BIND.
  5. Enrol connectors for each site, register apps, then turn on capabilities one at a time using the guides below — every feature is gated and off by default.

Example docker-compose.yml

# 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.

Your first hour

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.

  1. 1

    Stand up the broker

    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.

  2. 2

    Put TLS in front

    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.

  3. 3

    Make sign-in work

    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.

  4. 4

    Add device trust

    Hand out the desktop and mobile clients and enrol a device. Enrolment mints a hardware-backed key, so tokens are device-bound and the device can report its health.

  5. 5

    Reach a private app

    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.

  6. 6

    Turn on what you need

    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.

Glossary & standards

Plain-language definitions for every term and open standard Pramaan uses. Where a capability follows a published RFC, it's linked and explained.

OAuth 2.0RFC 6749

The industry-standard framework that lets an app get limited, delegated access on a user's behalf without ever seeing their password.

How the standard works

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.

OpenID Connect (OIDC)

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.

How the standard works

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.

PKCERFC 7636

“Proof Key for Code Exchange.” Stops an intercepted sign-in code from being reused by anyone but the app that started the flow.

How the standard works

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.

DPoPRFC 9449

Binds a token to a secret key held on one device, so a stolen token is useless anywhere else.

How the standard works

“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.

SAML 2.0

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.

How the standard works

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.

SCIM 2.0RFC 7644

A standard for automatically creating, updating and de-provisioning user accounts in downstream apps, so joiners and leavers stay in sync.

How the standard works

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.

LDAPRFC 4511

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.

How the standard works

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.

WebAuthn / FIDO2

The passkey standard: phishing-resistant sign-in with a device biometric or security key. The Pramaan app can itself act as a passkey.

How the standard works

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.

Transparency logRFC 6962

An append-only, cryptographically verifiable record — the same Merkle-tree idea behind Certificate Transparency.

How the standard works

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.

Model Context Protocol (MCP)

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.

How the standard works

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.

Zero-Trust Network Access (ZTNA)

Reaching private apps without a traditional VPN: nothing is trusted by network location, and each connection is authorised on its own merits.

How the standard works

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.

Secure Access Service Edge (SASE)

Delivering network security — web gateway, DNS filtering, inspection — as part of the access path rather than as separate appliances.

Secure Web Gateway (SWG)

The egress control point: it filters DNS, can inspect TLS, and blocks disallowed file types — by category, not just by hostname.

How the standard works

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.

Continuous Access (CAEP)

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.

How the standard works

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.

TUN device

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.

How the standard works

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.

JWKS

“JSON Web Key Set” — the public keys apps use to verify the tokens Pramaan issues.

How the standard works

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.

Capabilities

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.

CoreAlways part of the platform
On by defaultShips on / fail-closed; loosen only if you must
Available · offCoded and ready — flip the toggle to use it
CapabilityAreaStatusPrimary toggle
Desktop clientClient appsCoreinstall / always on
Mobile appClient appsCoreinstall / always on
Identity — OIDC, SAML, SCIM & LDAPIdentity & MFACoreBROKER_ISSUER
Operations & rollbackOperateCoreBROKER_SIGNING_KEY_FILE
Transparency log & CAEPMonitoring & AIOn by defaultBROKER_CAEP_ENABLE
Multi-site & VRF routingZero-Trust accessOn by defaultBROKER_ZTNA_TRUST_ADVERTISED_ROUTES
MFA, passkeys & device-bound tokensIdentity & MFAAvailable · offBROKER_DPOP_ENFORCE
Device posture & trust tiersIdentity & MFAAvailable · offBROKER_AGENT_POSTURE_ENABLE
Digital Experience MonitoringMonitoring & AIAvailable · offBROKER_DEM_ENABLE
Identity for AI agents & MCPMonitoring & AIAvailable · offBROKER_AGENTS_ENABLE
Policy Copilot (on-prem)Monitoring & AIAvailable · offBROKER_COPILOT_ENABLE
Risk-response runbooksMonitoring & AIAvailable · offBROKER_RUNBOOKS_ENABLE
ZTNA — private apps & connectorsZero-Trust accessAvailable · offBROKER_CONNECTOR_BIND
SSH to a private VM (raw-TCP how-to)Zero-Trust accessAvailable · offBROKER_TCP_TUNNEL_BIND
Always-on access (L3 tunnel)Zero-Trust accessAvailable · offBROKER_ZTNA_L3_ENABLE
Secure Web Gateway & DNS filteringZero-Trust accessAvailable · offBROKER_SWG_ENABLE
File-type control & sandboxZero-Trust accessAvailable · offBROKER_SWG_FILETYPE_BLOCK

Enablement matrix

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.

CapabilityCompile-timeA cargo feature baked into the client buildDesktop consoleA control in the desktop tray appBroker flagA BROKER_* env var on the brokerConnector envA CONNECTOR_* env var on the connectorAdmin consoleConfigured in the web admin console
Identity — OIDC / SAML / LDAPBROKER_ISSUER · BROKER_LDAP_BINDApplications · Sources · Property mappings
MFA, passkeys & device-bound tokensMobile app is a passkeyBROKER_DPOP_ENFORCE · BROKER_DPOP_ENFORCE_APIFlows · self-service account portal
Device posture & trust tiersReports automatically once enrolledBROKER_AGENT_POSTURE_ENABLE · BROKER_POSTURE_UNKNOWN_FAIL_CLOSEDPosture (weights, min score, unknown action)
ZTNA — private apps & connectorsRaw-TCP port forward (SSH/RDP/PG)BROKER_CONNECTOR_BIND · BROKER_CONNECTOR_ENABLE · BROKER_TCP_TUNNEL_BINDEnrol token + CONNECTOR_UPSTREAMS / CONNECTOR_ALLOWED_CIDRSConnectors · Applications · Access rules
Multi-site & VRF routingStatic routes (authoritative) · BROKER_ZTNA_CONNECTOR_STALE_SECSCONNECTOR_SITE · CONNECTOR_ROUTES · CONNECTOR_ADVERTISE_SECSSites · 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_IDSites → learned routes (read-only view)
Transparent access (always-on L3)Desktop l3 cargo feature“Start transparent access”BROKER_ZTNA_L3_ENABLE · BROKER_TCP_TUNNEL_BINDStandard ZTNA connectorApplications (host:port apps → fake-IP)
SASE — Secure Web Gateway & DLPBROKER_SWG_ENABLE · BROKER_SWG_DNS_BIND · BROKER_SWG_PROXY_BINDSWG policy · categories · DLP keywords
Digital Experience MonitoringAgent metrics once enrolledBROKER_DEM_ENABLE · BROKER_DEM_SYNTHETIC_SECSExperience dashboard · synthetic monitors
AI agents & MCP tool-brokerBROKER_AGENTS_ENABLE · BROKER_MCP_ENABLE · BROKER_AGENTS_PROVENANCEAgent registry
Policy Copilot & AdvisorBROKER_COPILOT_ENABLE · BROKER_COPILOT_URLPolicy Copilot (propose → verify → record)
Runbooks & risk eventsBROKER_RUNBOOKS_ENABLE · BROKER_RUNBOOKS_AUTORisk events · Runbooks
Transparency log, CAEP & SSFOn 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.

Identity & MFA

Identity — OIDC, SAML, SCIM & LDAP

Core capability

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.

You & your browser The app Pramaan 1Open the app 2Redirect to sign in · PKCE challenge 3Verify identity + MFA 4One-time code back to the app 5Exchange code + verifier → device-bound token 6Signed in
You never hand a password to the app — Pramaan verifies you and returns a short-lived, device-bound token.

How to enable

  1. Register an app: add an OIDC client or a SAML service provider under Applications in the admin console.
  2. Connect your directory: add an LDAP / FreeIPA source under Sources, then sync users and groups on demand or a timer.
  3. Shape the token: map directory groups and roles into the 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.
  4. For apps that only speak LDAP, turn on the LDAP server listener (BROKER_LDAP_BIND) and give them the read-only service bind DN.
  5. Tighten tokens with BROKER_SCOPE_ENFORCE so every client only ever receives the scopes it is registered for.
  6. Tune lifetimes without a restart: Settings → Token & session lifetimes sets the access-token, refresh-token and SSO session idle/absolute windows. Each value is clamped to a safe range on save, so a typo can't mint an instantly-expiring or effectively-immortal token.

Works with

Key settings

VariableWhat it doesDefault
BROKER_ISSUERPublic HTTPS base URL that identifies this broker as an OIDC issuer.http://localhost:8080
BROKER_SCOPE_ENFORCEConfines each request's scopes to the client's registered allow-list (openid always kept).false
BROKER_FEDERATION_CLAIM_ENRICHActivates identity-claim enrichment on the authentik federation path (the cutover switch).false
BROKER_ADDITIONAL_VERIFY_KEYS_FILEExtra public keys published in /jwks for zero-gap signing-key rotation.
BROKER_SAML_KEY_FILERSA assertion-signing key; with its cert it turns on the native SAML 2.0 IdP.
BROKER_LDAP_BINDBinds the LDAP-server listener so legacy apps can authenticate against Pramaan.— (disabled)

Example configuration

# 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.pem

MFA, passkeys & device-bound tokens

DPoP off by default (opportunistic)

Strong, 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.

How to enable

  1. Passkeys and TOTP are self-service from the account portal — no extra flag beyond serving the portal.
  2. For push and QR approvals, configure background push (FCM_SERVICE_ACCOUNT_FILE) and hand out the mobile app.
  3. Offer password sign-in only where you need it with BROKER_PASSWORD_LOGIN; enable lost-device recovery with BROKER_RECOVERY_ENROL.
  4. Brute-force is handled automatically: after 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).
  5. Keep DPoP opportunistic during rollout; once devices carry bound keys, set BROKER_DPOP_ENFORCE=true (walk the enforce checklist first).
  6. Extend proof-of-possession to the app APIs with 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.

Works with

Key settings

VariableWhat it doesDefault
BROKER_DPOP_ENFORCERequires a valid device-bound DPoP proof on /token, /userinfo and /introspect.false
BROKER_DPOP_ENFORCE_APIExtends 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_STEPUPRisk-based step-up: asks for MFA on impossible travel, bad reputation or an unfamiliar country.false
BROKER_AUTH_LOCKOUT_THRESHOLDFailed-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_THRESHOLDOptional 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_SECSHow long a pending push approval (and its auth code) stays valid.120
BROKER_PASSWORD_LOGINOffers username + password (live FreeIPA bind) as an alternate sign-in method.false
BROKER_RECOVERY_ENROLLets a single-use recovery code authorise enrolling a replacement device.false

Example configuration

# 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 device

Device posture & trust tiers

Fail-closed is opt-in

Every 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.

Signals (11) Score 0–100 Tier → access Encryption Firewall Screen lock Patched EDR / AV Secure Boot TPM Auto-update Managed SIP Gatekeeper 86 weighted · deterministic Trusted → full access Limited → capped / step-up Unknown → fail-closed
Eleven live signals become one weighted score, and the score maps to a trust tier that decides what the device can reach.

How to enable

  1. Posture is evaluated wherever a device reports it — the desktop tray and the mobile app do this automatically once enrolled.
  2. Turn on the ingest endpoint with BROKER_AGENT_POSTURE_ENABLE=true so desktop clients can report.
  3. Tune per-signal weights and the minimum passing score in the admin console (Posture) — the same model the client's health ring uses.
  4. Decide how unknown devices are treated: set BROKER_POSTURE_UNKNOWN_FAIL_CLOSED=true and choose deny or step_up.
  5. Optionally expire stale snapshots (BROKER_POSTURE_MAX_AGE_SECS) and cap non-trusted session lifetime (BROKER_LIMITED_TIER_TTL_SECS).

Works with

Key settings

VariableWhat it doesDefault
BROKER_AGENT_POSTURE_ENABLEEnables the /agent/posture ingest endpoint the desktop client reports signals to.false
BROKER_POSTURE_UNKNOWN_FAIL_CLOSEDTurns an Allow into a Deny for unknown-posture devices on ZTNA / forward-auth.false
BROKER_POSTURE_UNKNOWN_ACTIONWhat fail-closed does when it fires — deny or step_up.deny
BROKER_POSTURE_MAX_AGE_SECSAge after which a posture snapshot is treated as tier unknown; 0 never expires it.0 (off)
BROKER_LIMITED_TIER_TTL_SECSCaps session lifetime for non-trusted devices; 0 leaves standard TTLs.0 (off)
BROKER_KEY_ATTESTATION_ENABLEVerifies Android Key Attestation chains so hardware-backed is proven, not self-asserted.false

Example configuration

# 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)

Zero-Trust access

ZTNA — private apps & connectors

BROKER_CONNECTOR_ENABLE off by default

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.

How to enable

  1. Bind the connector listener (BROKER_CONNECTOR_BIND) with its TLS cert and set a registration key.
  2. Enrol a connector under Connectors to mint a one-time token; run it beside the app with that token and its upstream allow-list.
  3. Lock the pool: once enrolled, set BROKER_CONNECTOR_ENROLLED_ONLY=true so a leaked shared key can't join.
  4. Turn on the browser edge with BROKER_CONNECTOR_ENABLE=true, then register each private app (web or raw-TCP).
  5. Add an access rule for every private app before cutover — an app with zero rules fails closed and admits no one.

Works with

Key settings

VariableWhat it doesDefault
BROKER_CONNECTOR_BINDBinds the gated listener that dark-app connectors dial into over pinned TLS.
BROKER_CONNECTOR_ENABLEInstalls the edge that reverse-proxies browser requests to registered private web apps.false
BROKER_CONNECTOR_REGISTRATION_KEY_FILEShared key a connector must present in its HELLO (constant-time checked).
BROKER_CONNECTOR_ENROLLED_ONLYAccepts only per-connector enrolled tokens, refusing the shared registration-key bootstrap.false
BROKER_TCP_TUNNEL_BINDBinds the gated raw-TCP ingress (SSH / RDP / Postgres over ZTNA) for the client forwarder.

Example configuration

# 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)

Run a connector beside the app

# 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 networks

SSH to a private VM (raw-TCP how-to)

needs a connector + BROKER_TCP_TUNNEL_BIND

A 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.

SSH to a dark VM — no VPN, every connection re-authorised SSH clientTermius / ssh Forwarder127.0.0.1:2222 Brokeredge · public Connectorinside the VRF Linux VMsshd :22 local TCP pinned TLS + token dials OUT tcp :22 access rule: group + posture allowlist: tcp://vm-ip:22 only no inbound port opened anywhere SSH host-key + encryption run end-to-end, untouched, inside the Pramaan tunnel
Your SSH client talks to a local port; the forwarder tunnels it to the broker, which authorises the connection, and a connector relays it to the dark VM — the connector only ever dials the one host:port on its allow-list.

How to enable

  1. Run a connector inside the VM's network (or on the VM itself). Enrol it under Connectors for a one-time token, then set CONNECTOR_UPSTREAMS=tcp://<vm-ip>:22 — this allow-list is the whitelisting: the connector can dial that host and port and nothing else.
  2. Bind the raw-TCP ingress on the broker with 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).
  3. Register the SSH app (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.
  4. Add an access rule for the app before anyone uses it — an app with no rule fails closed and admits no one. Posture is evaluated per connection, so a non-compliant device is refused (and revoked mid-session by the posture sweep).
  5. Forward and connect. Desktop: 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.

Command-line (pramaanctl)

pramaanctl forward ssh-vm 2222Open 127.0.0.1:2222 and tunnel it to the ssh-vm app through the broker
ssh user@127.0.0.1 -p 2222Connect your SSH client to the forwarded local port

Client settings

VariableWhat it doesDefault
PRAMAAN_TCP_TUNNEL_ADDRhost:port of the broker's raw-TCP ingress the forwarder dials (pinned TLS)
FORWARDER_UPSTREAMSstandalone forwarder only: the local_port:app_id pairs to open

Works with

Key settings

VariableWhat it doesDefault
BROKER_TCP_TUNNEL_BINDBinds the gated raw-TCP ingress (SSH / RDP / Postgres over ZTNA) for the client forwarder.
BROKER_TCP_TUNNEL_REQUIRE_SCOPERequires the tunnel token to carry the pramaan.tunnel scope.false
BROKER_CONNECTOR_BINDBinds the gated listener that dark-app connectors dial into over pinned TLS.
BROKER_CONNECTOR_ENROLLED_ONLYAccepts 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.

Whitelist the VM on a connector

# 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.

Register the app, forward, connect

# 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.

Multi-site & VRF routing

Admin static routes only, by default

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.

How to enable

  1. Create a Site under Sites and add static routes — a CIDR like 10.1.0.0/16, an exact IP, a .suffix domain, a default 0.0.0.0/0, or *.
  2. Assign each connector to its site at enrolment; the site on that record is authoritative.
  3. Point a private app at the Site (leave its connector unpinned) — the broker routes by longest-prefix within the site.
  4. For active/standby across two connectors that both reach a destination, set a metric on each route (lower = primary). Blank is neutral. A degraded connector is demoted automatically — tune the window with BROKER_ZTNA_CONNECTOR_STALE_SECS.
  5. To let connectors contribute routes dynamically, turn on Accept learned routes for the site (or 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.
  6. Connectors re-advertise their reachable routes periodically (every 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.
  7. BGP-at-connector (optional, import-only): a connector can learn its site's prefixes from a routing peer instead of hand-maintained routes. Set 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.

Works with

Key settings

VariableWhat it doesDefault
BROKER_ZTNA_TRUST_ADVERTISED_ROUTESLets 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_SECSHealth-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_ONLYAccepts only per-connector enrolled tokens, refusing the shared registration-key bootstrap.false

Example configuration

# 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.)

Always-on access (L3 tunnel)

BROKER_ZTNA_L3_ENABLE off by default

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.

Per-app tunnel · on demand Laptop Broker One app One app at a time · no admin needed web tab or a forwarded local port Always-on (L3) · transparent Laptopvirtual adapter Broker App A App B App C Every protected app · needs admin once
Per-app opens one app on demand with no admin; always-on carries every protected app through one virtual adapter.
Always-on · per-connection decision Your appany TCP TUN adapterpramaan0 Classifymatch the destination Tunnel → broker → app Bypass → straight out Drop → fail-closed protected locked down
Inside always-on, each connection is matched to its destination and then tunnelled, sent straight out, or dropped fail-closed.

How to enable

  1. Enable the app-less ingress on the broker with BROKER_ZTNA_L3_ENABLE=true and bind the raw-TCP tunnel (BROKER_TCP_TUNNEL_BIND).
  2. Optionally require a dedicated scope on the tunnel token with BROKER_TCP_TUNNEL_REQUIRE_SCOPE=true.
  3. Install the always-on desktop build (packaged with the L3 feature) and turn on Always-on from the client console — it needs administrator once to create the adapter.
  4. Today always-on carries IP-based TCP apps; domain-based apps still use the per-app tunnel, and UDP to a protected destination is dropped fail-closed.

Works with

Key settings

VariableWhat it doesDefault
BROKER_ZTNA_L3_ENABLEEnables always-on L3 ingress: an app-less tunnel that carries only a destination is auto-resolved to a segment.false
BROKER_TCP_TUNNEL_BINDBinds the gated raw-TCP ingress (SSH / RDP / Postgres over ZTNA) for the client forwarder.
BROKER_TCP_TUNNEL_REQUIRE_SCOPERequires the tunnel token to carry the pramaan.tunnel scope.false

Example configuration

# 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 token

Secure Web Gateway & DNS filtering

BROKER_SWG_ENABLE off by default

Control 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.

Outbound request · Secure Web Gateway Deviceany app Gatewayauthenticate CategoriseDNS filter · rules InspectTLS · DLP · file-type Egressanti-SSRF guard Sinkhole / deny off-policy
Off-policy destinations are sinkholed or denied; everything else egresses through the anti-SSRF guard.

How to enable

  1. Set the master gate BROKER_SWG_ENABLE=true. For the web proxy add BROKER_SWG_PROXY_BIND; for DNS filtering add BROKER_SWG_DNS_BIND.
  2. Load category feeds (BROKER_SWG_CATEGORY_DIR) and write access rules — the proxy refuses to start if a configured feed fails to load.
  3. Turn on inline DLP with BROKER_DLP_ENABLE=true and an optional keyword file.
  4. To inspect HTTPS, provide an inspection CA, set BROKER_SWG_TLS_INSPECT=true and list the categories to decrypt — it never decrypts outside that list.

Works with

Key settings

VariableWhat it doesDefault
BROKER_SWG_ENABLEMaster gate for the Secure Web Gateway policy endpoint and its proxy / DNS listeners.false
BROKER_SWG_PROXY_BINDBinds the inline HTTP forward-proxy data plane (needs SWG enabled).
BROKER_SWG_DNS_BINDBinds the gated private-DNS filter (categorise → forward or sinkhole).
BROKER_SWG_CATEGORY_DIRDirectory of URL/domain category feeds that drive category-based policy.
BROKER_SWG_TLS_INSPECTEnables selective HTTPS inspection — only for Inspect-rule traffic in listed categories.false
BROKER_DLP_ENABLEMaster gate for the inline content-scan (/dlp/scan) endpoint.false
BROKER_SWG_FAIL_CLOSEDMakes SWG/DLP verdicts deny on store outage, rule error or no match.true

Example configuration

# 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"

File-type control & sandbox

Rides the SWG inspection path

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.

How to enable

  1. Requires the SWG proxy up, and for HTTPS the TLS inspection armed — it acts on the decrypted stream.
  2. List the categories to block with BROKER_SWG_FILETYPE_BLOCK (e.g. executable,archive,script).
  3. Catch spoofing with BROKER_SWG_FILETYPE_BLOCK_MISMATCH=true.
  4. For detonation, point BROKER_SWG_SANDBOX_URL at your sandbox; tune the buffered body cap with BROKER_SWG_INSPECT_MAX_BODY.

Works with

Key settings

VariableWhat it doesDefault
BROKER_SWG_FILETYPE_BLOCKMagic-byte-sniffed file categories to block, e.g. executable, archive, script.— (none)
BROKER_SWG_FILETYPE_BLOCK_MISMATCHBlocks a declared Content-Type that disagrees with the sniffed true type.false
BROKER_SWG_SANDBOX_URLOn-prem detonation sandbox to submit downloads to; a malicious verdict blocks.
BROKER_SWG_INSPECT_MAX_BODYMax response body buffered for inline DLP / sandbox inspection (bytes).10485760

Example configuration

# 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/scan

Monitoring & AI

Digital Experience Monitoring

BROKER_DEM_ENABLE off by default

Measure 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.

How to enable

  1. Set BROKER_DEM_ENABLE=true to open the /dem ingest and the admin DEM API.
  2. Create monitors under Digital experience: an HTTP(S) URL or a host:port TCP target, with latency and loss thresholds.
  3. For datacentre signal now, flag a monitor synthetic and set BROKER_DEM_SYNTHETIC_SECS (e.g. 60) so the broker probes it on a timer.
  4. For the real per-user experience, the desktop and mobile clients probe automatically and report over their token — a no-op until monitors exist. Test one pass with pramaanctl dem.

Works with

Key settings

VariableWhat it doesDefault
BROKER_DEM_ENABLEEnables the /dem ingest and admin DEM API; off means the routes 404 and nothing is recorded.false
BROKER_DEM_SYNTHETIC_SECSInterval for the broker's synthetic prober; only probes monitors flagged synthetic.0 (off)

Example configuration

# Digital Experience Monitoring
BROKER_DEM_ENABLE: "true"
BROKER_DEM_SYNTHETIC_SECS: "60"     # broker probes synthetic monitors every 60s (0 = off)

Identity for AI agents & MCP

BROKER_AGENTS_ENABLE off by default

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.

How to enable

  1. Set BROKER_AGENTS_ENABLE=true to open the agent registry and the client-credentials grant.
  2. Keep BROKER_AGENTS_REQUIRE_ENROLLED_DEVICE=true so an agent secret alone is not enough — the key must be an owner-enrolled device.
  3. Turn on the tool broker with BROKER_MCP_ENABLE=true, and signed receipts with BROKER_AGENTS_PROVENANCE=true.
  4. Draft agent policies in plain English with the Policy Copilot, review them, then enable.

Works with

Key settings

VariableWhat it doesDefault
BROKER_AGENTS_ENABLEEnables the agent registry and the client_credentials grant for AI-agent identities.false
BROKER_AGENTS_REQUIRE_ENROLLED_DEVICERequires an agent's DPoP key to belong to an owner-enrolled device before minting a token.true
BROKER_MCP_ENABLEEnables the /mcp/authorize per-tool Allow/Deny broker.false
BROKER_AGENTS_PROVENANCEAdds a signed ES256 provenance receipt to every agent token-mint and tool authorisation.false

Example configuration

# 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 receipts

Policy Copilot (on-prem)

BROKER_COPILOT_ENABLE off by default

A 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.

How to enable

  1. Run a local OpenAI-compatible SLM server on an internal host.
  2. Point BROKER_COPILOT_URL at it — it must be loopback / private / localhost, or the broker refuses to start.
  3. Set BROKER_COPILOT_ENABLE=true; tune the abstention floor with BROKER_COPILOT_MIN_CONFIDENCE.
  4. Optionally pin BROKER_COPILOT_MODEL_SHA256 so receipts attest a known model build.

Works with

Key settings

VariableWhat it doesDefault
BROKER_COPILOT_ENABLEMaster switch for the on-prem Policy Copilot (admin listener only).false
BROKER_COPILOT_URLBase URL of the LOCAL SLM server — must be loopback / private / localhost or the broker refuses to start.
BROKER_COPILOT_MODELModel id sent to the local SLM server.qwen3-1.7b-instruct-q4_k_m
BROKER_COPILOT_MIN_CONFIDENCEConfidence floor below which the copilot abstains instead of guessing.0.0 (off)
BROKER_COPILOT_MODEL_SHA256Model-weights hash recorded verbatim in a committed rule's reproducibility receipt.

Example configuration

# 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 this

Risk-response runbooks

BROKER_RUNBOOKS_ENABLE off by default

When 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.

How to enable

  1. Set BROKER_RUNBOOKS_ENABLE=true; with it off, the engine is a complete no-op.
  2. Watch the Risk events page in the admin console — entries appear the moment a real signal (posture drop, token reuse, device non-compliance, brute-force lockout, impossible travel) occurs; never synthetic.
  3. Tune the remediation per signal from the Runbook rules panel on that page; leave rules advisory, or tick auto-apply for the ones you trust.
  4. Optionally add a per-signal script: it reads the read-only 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.
  5. Scripts are rhai. Write a decision as an if/else that yields a quoted action string — e.g. 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.
  6. With the on-prem AI copilot on (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.
  7. To let auto rules actually act, also set BROKER_RUNBOOKS_AUTO=true — without it, every rule stays advisory. Step-up never auto-applies.
  8. Pair it with BROKER_REATTEST_ON_REFRESH so each refresh re-checks device posture — that keeps the tier current, so a real drop is detected promptly.

Works with

Key settings

VariableWhat it doesDefault
BROKER_RUNBOOKS_ENABLETurns 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_AUTOMaster 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_REFRESHRe-runs posture + policy on every refresh-token grant (continuous authorisation).false

Transparency log & CAEP

Log is public by design

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.

How to enable

  1. The log is public by design — verifiability needs open access — and served with its own permissive CORS.
  2. Enable shared-signals streams with BROKER_CAEP_ENABLE=true and register receivers.
  3. Anchor the receipt chain head with a post-quantum key via BROKER_SLHDSA_SECRET_FILE.
  4. Verify receipts and inclusion proofs from the admin console Transparency section — the browser first checks the signed tree head's signature against the public keys, so every proof is anchored to a root the server actually signed, not one it merely claims.
  5. Device revocations are attributable: the audit entry records the acting principal and the IP it came from alongside the device's owner, for both the app's 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.

Works with

Key settings

VariableWhat it doesDefault
BROKER_CAEP_ENABLEPublishes the SSF configuration and pushes signed CAEP session-revoked events to receivers.false
BROKER_SLHDSA_SECRET_FILEPost-quantum (FIPS 205) key that anchors the tamper-evident receipt chain head.

Example configuration

# Continuous access (shared signals) + PQ receipt anchor
BROKER_CAEP_ENABLE: "true"
BROKER_SLHDSA_SECRET_FILE: /keys/slhdsa_secret.b64

Client apps

Desktop client

Windows · macOS · Linux

A 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.

How to enable

  1. Install the signed package for the platform (the always-on build is packaged with the L3 feature included).
  2. Sign in and enrol: pramaanctl login then pramaanctl enroll — this registers a hardware-backed device key.
  3. Open the console with pramaanctl console to see device health, reach apps, manage approvals and toggle always-on.
  4. Point it at your broker with PRAMAAN_BROKER (or pramaanctl config set-broker); it keeps itself current via signed auto-update.

Command-line (pramaanctl)

loginBrowser sign-in (PKCE) with the broker.
enroll [name]Register this device's hardware-backed key with the broker.
status / deviceShow sign-in and enrolment state.
listList 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).
l3Start always-on transparent access (needs administrator).
postureCollect, sign and report device health; print the returned tier.
demRun one experience-monitoring pass and report the samples.
approvals / approve / denyList and answer pending sign-in approvals.
consoleOpen the local web console (port 47010).
update [--apply]Check for and install a newer signed release.

Client settings

VariableWhat it doesDefault
PRAMAAN_BROKERBroker base URLhttps://pramaanid.cloud
PRAMAAN_DEM_DISABLEOpt out of experience probing— (on)
PRAMAAN_DEM_INTERVAL_SECSExperience probe interval300
PRAMAAN_POSTURE_INTERVAL_SECSPosture report interval (floor 60s)600

Works with

Example configuration

# 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-on

Mobile app

Android-first

The 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.

How to enable

  1. Install the app and sign in; then enrol the device — one biometric check generates a hardware-backed key (plus a post-quantum co-signing key).
  2. Add more accounts by scanning a provisioning QR or entering an issuer and client id; each account enrols its own device key.
  3. Approvals, QR sign-in and TOTP work out of the box; the broker features (offline codes, device manager, sync, experience probing) light up when the account points at a Pramaan broker.
  4. On Android 14+, enable Pramaan as a passkey provider in system settings to use it as a roaming authenticator.

Works with

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.

Example configuration

# 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.cloud

Operate

Operations & rollback

Core capability

The 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.

How to enable

  1. Mount the signing key at BROKER_SIGNING_KEY_FILE so the JWKS key-ids never change across restarts.
  2. Before a redeploy, tag the running image as rollback-<name>; rebuild and bring it up; roll back by re-tagging if needed.
  3. After any deploy, confirm the JWKS key-ids are unchanged and /healthz returns 200.
  4. Serve this documentation by setting BROKER_DOCS_DIR; it appears at /docs and is linked from the admin console.

Key settings

VariableWhat it doesDefault
BROKER_SIGNING_KEY_FILEFile-mounted ES256 token-signing key so the JWKS key-ids stay stable across restarts.— (ephemeral)
BROKER_DOCS_DIRDirectory of this documentation site, served read-only under /docs.
DATABASE_URLPostgreSQL connection string; without it all state is in-memory and lost on restart.— (in-memory)
GEOIP_DIRDirectory of GeoIP databases used for location and risk signals../geoip

Example configuration

# 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

Reverse proxy (nginx / Caddy)

# 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
# }

Integration recipes

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.

The building blocks — common to every integration

EndpointPathWhat it's for
Discovery/.well-known/openid-configurationPoint any OIDC app here and it auto-configures every endpoint below.
Authorization/authorizeWhere users are sent to sign in (authorization-code + PKCE).
Token/tokenExchanges the code for tokens; also the client-credentials grant.
UserInfo/userinfoReturns the signed-in user's claims (subject, email, groups).
Public keys/jwksThe keys apps use to verify token signatures (stable key-ids).
Logout/logoutRP-initiated logout / end-session.
SAML metadata/saml/<slug>/metadataIdP metadata for a registered SAML app; SSO at <code>/saml/&lt;slug&gt;/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.

Grafana (and any OIDC app)OpenID Connect

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'
A SAML applicationSAML 2.0

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.pem
A legacy LDAP appLDAP

Some 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)
NextcloudOpenID Connect

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_username
GitLabOpenID Connect

GitLab 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'
    }
  }
}]
Any web app behind a reverse proxyForward-auth

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; }
Kubernetes API serverOpenID Connect

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-admins

Configuration builder

Choose 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.

Start from
Consoles
Admin consoleBROKER_ADMIN_BIND
Self-service portalBROKER_ACCOUNT_UI_DIR
This documentation siteBROKER_DOCS_DIR
Identity & MFA
Enforce token scopesBROKER_SCOPE_ENFORCE
Adaptive step-up (risk-based MFA)BROKER_ADAPTIVE_STEPUP
SAML 2.0 identity providerBROKER_SAML_KEY_FILE…
LDAP server (legacy apps)BROKER_LDAP_BIND
Device trust
Enforce device-bound tokensBROKER_DPOP_ENFORCE
Device-posture gatingBROKER_AGENT_POSTURE_ENABLE…
Zero-Trust access
ZTNA private apps + connectorsBROKER_CONNECTOR_BIND…
Always-on (L3) accessBROKER_ZTNA_L3_ENABLE
Secure Web Gateway + DNS filterBROKER_SWG_ENABLE…
Monitoring & AI
Digital Experience MonitoringBROKER_DEM_ENABLE…
Identity for AI agents + MCPBROKER_AGENTS_ENABLE…
On-prem Policy CopilotBROKER_COPILOT_ENABLE…
Continuous access (CAEP)BROKER_CAEP_ENABLE
Risk-response runbooks (advisory)BROKER_RUNBOOKS_ENABLE

Configuration reference

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.

Identity, tokens & keys

VariableDescriptionDefault
BROKER_ENRICH_CLIENT_IDS
BROKER_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_SECSHow 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_FILE
BROKER_RS256_KEY_PEM
Adds an RSA key so clients that pin RS256 receive RS256-signed id_tokens.
BROKER_SAML_CERT_FILE
BROKER_SAML_CERT_PEM
X.509 certificate (PEM) matching Self::saml_key_pem, published in the IdP metadata and <ds:KeyInfo>. /
BROKER_SAML_KEY_FILE
BROKER_SAML_KEY_PEM
RSA assertion-signing key; with its cert it turns on the native SAML 2.0 IdP.
BROKER_SAML_SIGN_METADATASign 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 importsfalse
BROKER_SAML_SP_CLOCK_SKEW_SECSHow 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 honoured60
BROKER_SAML_SP_ENABLEHost 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 stopsfalse
BROKER_SCOPE_ENFORCEConfines each request's scopes to the client's registered allow-list (openid always kept).false
BROKER_SLHDSA_SECRET_FILE
BROKER_SLHDSA_SECRET_B64
Post-quantum (FIPS 205) key that anchors the tamper-evident receipt chain head.

Admin, portal & docs

VariableDescriptionDefault
BROKER_ACCOUNT_OIDC_CLIENT_IDOIDC client_id the self-service portal signs in with (PKCE public client); read at runtime from /account/config.js"account-portal"
BROKER_ACCOUNT_UI_DIRDirectory of the end-user self-service portal, served under /account.
BROKER_ADMIN_BINDBinds the separate, gated admin API + console — never on the public socket.— (disabled)
BROKER_ADMIN_CONSOLE_HOSTHost 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_GROUPGroup a signed-in user must belong to for admin access.pramaan-admins
BROKER_ADMIN_OIDC_CLIENT_IDOIDC 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_ORIGINSBrowser 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_SCOPEAlso requires the pramaan.admin scope on admin tokens.false
BROKER_ADMIN_TRUSTED_CIDRSSource-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_DIRDirectory of the built admin console SPA; unset serves the API only.
BROKER_DOCS_DIRDirectory of this documentation site, served read-only under /docs.

MFA, approvals & OTP

VariableDescriptionDefault
BROKER_DESKTOP_ENROLLets a desktop client (no Play Integrity) enrol on its verified login alone — never the trusted tier.false
BROKER_PASSWORD_BREACH_API_URLOptional 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_PARALLELISMHow 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_LOGINOffers username + password (live FreeIPA bind) as an alternate sign-in method.false
BROKER_RECOVERY_ENROLLets a single-use recovery code authorise enrolling a replacement device.false

Device posture & attestation

VariableDescriptionDefault
BROKER_ADAPTIVE_STEPUPRisk-based step-up: asks for MFA on impossible travel, bad reputation or an unfamiliar country.false
BROKER_AGENT_POSTURE_ENABLEEnables the /agent/posture ingest endpoint the desktop client reports signals to.false
BROKER_ATTEST_ON_APPROVALVerifies a fresh Play Integrity token at approval time, not just at enrolment.false
BROKER_DEVICE_CA_CERT_FILE
BROKER_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_SECSValidity (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-issuance7 * 24 * 60 * 60
BROKER_DEVICE_MTLS_ENABLEMaster 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 unchangedfalse
BROKER_DPOP_ENFORCERequires a valid device-bound DPoP proof on /token, /userinfo and /introspect.false
BROKER_DPOP_ENFORCE_APIExtends 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_ENABLEVerifies Android Key Attestation chains so hardware-backed is proven, not self-asserted.false
BROKER_KEY_ATTESTATION_REQUIRE_STRONGBOXRequire StrongBox (a dedicated secure element), not just TEE, before a key counts as hardware-backed. Default false = TEE is sufficientfalse
BROKER_KEY_ATTESTATION_ROOTSTrusted Google attestation root certs; without them nothing can be hardware-backed.— (fail closed)
BROKER_LIMITED_TIER_TTL_SECSCaps session lifetime for non-trusted devices; 0 leaves standard TTLs.0 (off)
BROKER_POSTURE_MAX_AGE_SECSAge after which a posture snapshot is treated as tier unknown; 0 never expires it.0 (off)
BROKER_POSTURE_SWEEP_SECSPeriod of the background sweep that revokes sessions whose device is gone or degraded.0 (off)
BROKER_POSTURE_UNKNOWN_ACTIONWhat fail-closed does when it fires — deny or step_up.deny
BROKER_POSTURE_UNKNOWN_FAIL_CLOSEDTurns an Allow into a Deny for unknown-posture devices on ZTNA / forward-auth.false
BROKER_REATTEST_ON_REFRESHRe-runs posture + policy on every refresh-token grant (continuous authorisation).false

ZTNA — connectors, sites & L3

VariableDescriptionDefault
BROKER_CONNECTOR_BINDBinds the gated listener that dark-app connectors dial into over pinned TLS.
BROKER_CONNECTOR_CLIENT_CA_FILEOptional 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_ENABLEInstalls the edge that reverse-proxies browser requests to registered private web apps.false
BROKER_CONNECTOR_ENROLLED_ONLYAccepts only per-connector enrolled tokens, refusing the shared registration-key bootstrap.false
BROKER_CONNECTOR_KEY_FILE
BROKER_CONNECTOR_CERT_FILE
TLS server key + cert (PEM) for the connector listener; the connector pins this cert. /_PEM + /_PEM
BROKER_CONNECTOR_PUBLIC_ADDR
CONNECTOR_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_KEYShared 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_DOMAINCookie 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_ADDRRAC (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_BINDBinds the gated raw-TCP ingress (SSH / RDP / Postgres over ZTNA) for the client forwarder.
BROKER_TCP_TUNNEL_REQUIRE_SCOPERequires the tunnel token to carry the pramaan.tunnel scope.false
BROKER_ZTNA_CONNECTOR_STALE_SECSHealth-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_ENABLEEnables always-on L3 ingress: an app-less tunnel that carries only a destination is auto-resolved to a segment.false
BROKER_ZTNA_TRUST_ADVERTISED_ROUTESLets 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

SASE — Secure Web Gateway & DLP

VariableDescriptionDefault
BROKER_DLP_ENABLEMaster gate for the inline content-scan (/dlp/scan) endpoint.false
BROKER_DLP_KEYShared key the calling scanner must present (constant-time) on /dlp/scan. Secret; never logged. (file via _FILE, else inline)
BROKER_SWG_ALLOW_NO_SNIPermit 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 costfalse
BROKER_SWG_ALLOW_PUBLIC_BINDPermit binding the proxy listener to a public (non-loopback/private) address. Default false — a guard against accidentally exposing the proxy to the internetfalse
BROKER_SWG_ANALYTICS_ENABLERecord 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 granularityfalse
BROKER_SWG_CATEGORY_DIRDirectory of URL/domain category feeds that drive category-based policy.
BROKER_SWG_CATEGORY_RELOAD_SECSHow often (seconds) to reload the category feeds. Default 36003600
BROKER_SWG_CONNECT_PORTSComma-separated list of ports the proxy will CONNECT to. Default "443" (HTTPS only)"443"
BROKER_SWG_DENY_NETSExtra 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_FILEFile of DLP keywords/patterns (one per line) for inline content scanning. Unset means no keyword DLP
BROKER_SWG_DNS_BIND
BROKER_SWG_ENABLE
Binds the gated private-DNS filter (categorise → forward or sinkhole).
BROKER_SWG_DNS_SINKHOLE_IPIPv4 address returned for BLOCKED A queries (a sinkhole / black-hole). Default 0.0.0.0"0.0.0.0"
BROKER_SWG_DNS_UPSTREAMUpstream resolver the DNS filter forwards ALLOWED queries to (host:port). Default 1.1.1.1:53"1.1.1.1:53"
BROKER_SWG_ENABLEMaster gate for the Secure Web Gateway policy endpoint and its proxy / DNS listeners.false
BROKER_SWG_FAIL_CLOSEDMakes SWG/DLP verdicts deny on store outage, rule error or no match.true
BROKER_SWG_FILETYPE_BLOCKMagic-byte-sniffed file categories to block, e.g. executable, archive, script.— (none)
BROKER_SWG_FILETYPE_BLOCK_MISMATCHBlocks a declared Content-Type that disagrees with the sniffed true type.false
BROKER_SWG_INSPECT_MAX_BODYMax response body buffered for inline DLP / sandbox inspection (bytes).10485760
BROKER_SWG_KEYShared 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_BINDBinds the inline HTTP forward-proxy data plane (needs SWG enabled).
BROKER_SWG_REQUIRE_AUTHRequire an authenticated identity for proxy egress. Secure default TRUE; set it to false only for a transitional deploymenttrue
BROKER_SWG_SANDBOX_TIMEOUT_MSHard end-to-end timeout for an inline sandbox submission. A sandbox that is slow/unreachable is best-effort (does not block). Default 8000 ms8000
BROKER_SWG_SANDBOX_URLOn-prem detonation sandbox to submit downloads to; a malicious verdict blocks.
BROKER_SWG_TLS_INSPECTEnables selective HTTPS inspection — only for Inspect-rule traffic in listed categories.false
BROKER_SWG_TLS_INSPECT_CATEGORIESThe only categories ever decrypted; empty means inspect nothing even with the flag on.— (none)
BROKER_SWG_TLS_INSPECT_CA_CERT_FILEThe 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

Digital Experience Monitoring

VariableDescriptionDefault
BROKER_DEM_ENABLEEnables the /dem ingest and admin DEM API; off means the routes 404 and nothing is recorded.false
BROKER_DEM_SYNTHETIC_SECSInterval for the broker's synthetic prober; only probes monitors flagged synthetic.0 (off)

AI agents & MCP

VariableDescriptionDefault
BROKER_AGENTS_ENABLEEnables the agent registry and the client_credentials grant for AI-agent identities.false
BROKER_AGENTS_PROVENANCEAdds a signed ES256 provenance receipt to every agent token-mint and tool authorisation.false
BROKER_AGENTS_REQUIRE_ENROLLED_DEVICERequires an agent's DPoP key to belong to an owner-enrolled device before minting a token.true
BROKER_MCP_ENABLEEnables the /mcp/authorize per-tool Allow/Deny broker.false

Policy Copilot & Advisor

VariableDescriptionDefault
BROKER_ADVISOR_ENABLEMaster 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. Offfalse
BROKER_ADVISOR_STALE_DAYSDays without a policy-engine hit before an enabled access rule is reported stale by the advisor's collector. Default 3030
BROKER_COPILOT_ENABLEMaster switch for the on-prem Policy Copilot (admin listener only).false
BROKER_COPILOT_MIN_CONFIDENCEConfidence floor below which the copilot abstains instead of guessing.0.0 (off)
BROKER_COPILOT_MODELModel id sent to the local SLM server.qwen3-1.7b-instruct-q4_k_m
BROKER_COPILOT_MODEL_SHA256Model-weights hash recorded verbatim in a committed rule's reproducibility receipt.
BROKER_COPILOT_REFINE_MAXMax 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 33
BROKER_COPILOT_REFINE_WALL_MSWall-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 9000090_000
BROKER_COPILOT_TIMEOUT_MSHard end-to-end timeout (milliseconds) for one SLM completion. Default 3000030_000
BROKER_COPILOT_URLBase URL of the LOCAL SLM server — must be loopback / private / localhost or the broker refuses to start.

Transparency, CAEP & SSF

VariableDescriptionDefault
BROKER_CAEP_ENABLEPublishes the SSF configuration and pushes signed CAEP session-revoked events to receivers.false
BROKER_SSF_REGISTRATION_KEYShared 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)

LDAP provider

VariableDescriptionDefault
BROKER_LDAP_BASE_DNThe 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_BINDBinds the LDAP-server listener so legacy apps can authenticate against Pramaan.— (disabled)
BROKER_LDAP_KEY_FILE
BROKER_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_DNService-account bind DN that apps use to search the directory (read-only). Defaults to cn=ldapservice,ou=users,<base>
BROKER_LDAP_SERVICE_PASSWORDPassword 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_STARTTLSStartTLS 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 portfalse
BROKER_LDAP_TRUSTED_CIDRSSource-IP allowlist (CIDRs) for the LDAP listener — defense in depth on top of bind auth. Empty = allow any peer that reaches the listener

Other settings

VariableDescriptionDefault
BROKER_APP_INSTALL_URLPublic 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_ENABLEPeriodically 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 infalse
BROKER_DEVICE_REQUIRE_SCOPERequire 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 immediatelyfalse
BROKER_ENROLL_GRANT_TTL_SECSSeconds 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 grant15 * 60
BROKER_INVITE_RETENTION_SECSHow 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 does30 * 24 * 60 * 60
BROKER_INVITE_TTL_SECSHow 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 all72 * 60 * 60
BROKER_L3_FULLTUNNEL_ENABLESafety 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 allowlistfalse
BROKER_METRICS_FLUSH_SECSSeconds 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 3030
BROKER_METRICS_TOP_KHow 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 200200
BROKER_PLATFORM_ADMINSUsernames 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_HOSTHost 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_DIRDirectory 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_SIZELargest 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 memoryDEFAULT_SCIM_MAX_PAGE_SIZE
BROKER_SCIM_SERVER_ENABLEHost 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 itfalse
BROKER_SECRET_KEYWhether 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_SECSSeconds 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 refresh0
BROKER_SMTP_FROMEnvelope sender and From: header, e.g. Pramaan ID <no-reply@example.com>
BROKER_SMTP_HOSTSubmission 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_FILE
BROKER_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_PORTSubmission 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 design465
BROKER_SMTP_REPLY_TOOptional Reply-To:. Unset means replies go to the unattended submission mailbox, which is rarely what an invitee needs
BROKER_SMTP_USERNAMESubmission 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_KEYLet 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-middlefalse
BROKER_TENANT_BASE_DOMAINBase 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_KEKBase64 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_BINDBind 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