BeyondTrust/POV

← Back to dashboard

Config Management

Audience: operator · Profile: both · Read this when: you are about to run an Ansible job and want to know how the runner handles secrets and isolation.

Part of Operations.

This document explains how the dashboard does config management — the philosophy that drives the design, the best practices the codebase encodes, and how the on-premises and cloud paths fit together.

If your dashboard runs in a cloud (ECS / ACI / Container Apps), the "Local Docker" runner below cannot serve your on-premises targets: it is a sibling container on the dashboard's host, which has neither a Docker socket nor a route to your LAN. Those targets are reached by a remote agent instead, which runs the same one-shot container inside your network. Everything else in this document — the asset types, the secret handling, the drift tracking — is unchanged.

The companion docs:

The page

/config-mgmt has three tabs:

The URL fragment selects a tab, so /config-mgmt#runs opens straight on Recent runs. A bulk selection handed over from the Inventory page always opens on Run.

Inside Run, the Use a secret section is collapsed by default. It holds the named secret variables, become method and password, SSH key, EPM-L token variable and the managed-account picker. It opens by itself for a bulk run and as soon as any of those fields has a value, and its header shows an in use badge while one does. A collapsed section still applies: everything set in it goes with the run.


Philosophy

Config management lives or dies on three principles. We try to bake all three into the dashboard rather than leave them as user discipline.

1. Declarative, not procedural. You describe the state you want; the runner figures out whether work is needed. A playbook that says "nginx should be installed and running" is right whether nginx is already there or not. A shell script that says "apt install nginx && systemctl start nginx" is wrong on the second run, on a yum system, on a host that's already serving traffic.

2. Version-controlled assets. Playbooks and scripts are code. They go in source control, get reviewed, get rolled back when wrong. The dashboard's storage layer is the runtime distribution channel — your git history is still authoritative. Enable versioning on the underlying bucket (S3, Azure Blob, GCS all support it) so the runtime store is also recoverable.

3. Separation of what from where. The asset describes the desired state. The inventory says which hosts to apply it to. Mixing the two ("install nginx on web-01") couples the two and turns a 20-host fleet into 20 unique playbooks.


How the dashboard implements these

Principle Where it shows up
Declarative .yml / .yaml playbooks run as-is via Ansible's idempotent modules. .sh / .ps1 / .rpm / .deb are auto-wrapped in a generated playbook that uses idempotent built-in modules (copy, script, dnf, apt, win_script).
Version-controlled Assets are uploaded to the storage backend you select. Bucket versioning + your own git remote together give you history. The dashboard never overwrites blindly — every upload is a new object at the same key.
Separation of what/where The Config Management page (/config-mgmt) picks what (asset) and where (inventory group or cloud target) independently. The same playbook can target the on-prem proxmox group, an EC2 instance by IP, or both.

The execution paths

The dashboard surfaces three distinct execution paths, all reaching the same Config Management page. The first two SSH/WinRM to a host; the third runs a localhost play that reaches out to a managed service.

On-premises hypervisors

If you've enabled any of the on-prem hypervisor integrations (Proxmox VE, vSphere/ESXi, Hyper-V, Nutanix AHV, XCP-ng, VMware Workstation), the dashboard auto-builds an Ansible inventory from each integration's configured host list. Targets appear in the run-asset dropdown as group keys (e.g. proxmox, vsphere).

Behind the scenes (see services/ansible_local_service.py):

Two limits of that path are worth knowing before you rely on it, and a remote agent is the answer to both:

This is where contributors with on-prem labs help most — see CONTRIBUTING.md → Where the community can help most.

Cloud providers (AWS / Azure / GCP)

Cloud VMs you've deployed via the dashboard appear in the same target dropdown, prefixed with aws:, azure:, or gcp:. Picking one tells the runner three things:

Cloud runs can use any of the four runners — the choice mostly affects where the Ansible process executes, not the playbook semantics. See the runner section below.

Kubernetes clusters & databases (localhost plays)

Registered or provisioned Kubernetes clusters and databases appear in the same target dropdown, under their own groups. A database imported from Password Safe is an ordinary registered row and behaves identically as a target. These are not SSH targets — Ansible's kubernetes.core and community.postgresql/mysql/general modules run on the controller (hosts: localhost, connection: local) and reach out to the API server (via a kubeconfig) or the DB endpoint (via login vars). So the model is inverted from the VM paths, and three things follow:

Runs are dispatched by the durable job worker (ansible_cloud_run job type) rather than an in-process background task, since they launch a cloud task that can outlive a request worker's recycle.

Scope note: the stored kubeconfig is cluster-admin and the database credential is the admin/master login — a localhost play has full rights. Treat these playbooks accordingly. Starters live in examples/playbooks/k8s/ and examples/playbooks/database/.


Running inside a change window

A run does not have to start now. The When to run control on the run form can book it for a specific time, or for the next occurrence of a named change window — and a booked run that misses its window is marked missed rather than run late. Bulk runs book as a unit, so a batch is never split across a window boundary.

Nothing about the run itself changes: the job row is identical, the same validation happens at submit time, and credentials are still resolved at execution time rather than stored. See Change Windows.

Bulk runs from the inventory

The Config Management page runs one asset against one target. To apply a playbook across a fleet, use the Inventory page (/inventory): filter to what you want, tick the rows, and a run panel appears. Each selected resource becomes its own job, all tagged with a shared batch_id — so one host failing doesn't roll back the others, and each job keeps its own log and output scrubbing.

Runs are queued work, not request-side work: the endpoint writes a job row and the job runner claims it. So a batch survives a dashboard restart mid-flight, and its jobs spread across WORKER_REPLICAS (default 3) instead of executing one at a time. Worth knowing before firing a large batch — three playbooks run against three hosts at once, and raising WORKER_REPLICAS raises that concurrency.

Queueing a batch lands you on /jobs?batch_id=…, filtered to just that run, with a rollup across the whole batch — N total · N running · N failed — rather than the one page of rows the table happens to show. The URL is shareable, and any job that belongs to a batch carries a batch badge linking back to its siblings, so you can still find a run after the toast is gone.

One kind per run. Selecting a VM locks the checkboxes on Kubernetes clusters and databases, and vice versa. This isn't a UI convenience — the kinds are not interchangeable at any level. A VM run SSHes to a host; k8s and database runs are localhost plays that reach out over a kubeconfig or DB login. Different request fields, a different runner, and a playbook written for one is meaningless against another. A mixed selection could only ever produce a pile of failed jobs, so it is refused rather than attempted.

Rows that can't be a target at all are disabled, with the reason on hover:

Row Why it's disabled
Virtual desktops No Ansible target exists behind a seat.
Proxmox / Nutanix VMs Their deploy records a node + VMID, not an address. Target them through their hypervisor group on the Config Management page instead.
Databases with an unsupported engine The runner image ships client libraries for postgres / mysql / sqlserver only.
Clusters or databases in a cloud with no runner See Runners.

Those reasons come from the server, computed by the same rule the endpoint enforces, so the page can never offer a checkbox the API would reject.

Two limits worth knowing. A batch is capped at 50 targets — each one is a job, so a mis-clicked "select all" against a large estate would otherwise fan out unbounded work. And while selection problems refuse the whole request before any job exists, a per-target failure at dispatch does not: several checks depend on the target's cloud, so a mixed-cloud VM batch can be valid for one host and not another. Those targets come back in the response's failed list and are named in the toast; the rest still run.

Secrets and managed accounts in a bulk run

The inventory panel covers the common case — asset, SSH user, extra vars. For a run that needs a Secrets-Management secret or a Password Safe managed account, use Continue on the Config Management page →. It carries the selection over and the full run form applies to it: named secret vars, become password, SSH key, and the managed-account picker. The page opens on the Run tab with the Use a secret section already expanded.

Every target picks its own managed account. The form lists each selected VM with its own Password Safe dropdown, read from that target's own address. Pick a default above the table to pre-select every row, then override only the rows that differ — a fleet whose hosts carry svc-ansible-web01, svc-ansible-db02 and so on is expressed directly, rather than needing one account name to be true everywhere.

The suggestion for each row is made server-side, in three tiers: the account matching the default name you chose; the Password Safe system that VM was actually onboarded into (recorded at registration — the only thing that finds a plugin-onboarded system, which carries a 127.0.0.1 placeholder and no usable address); or a single unambiguous candidate. Rows say which tier they came from, so an inference is distinguishable from your own pick. There is deliberately no fuzzy name matching: a near-miss here checks out the wrong machine's credential.

Why the ids matter. A ManagedAccountRef pins system_id + account_id, and both belong to one managed system — one such ref shared by a whole batch would check out a single machine's credential and connect to every host with it. Per-target refs are pinned because each one came from its own host's live list, and the server checks every key against the selection it resolved for itself. The default is sent as a name only, for the same reason: it applies to hosts whose own list was never read. A scheduled batch sends names throughout — an id pinned today can name an account that has been removed by the time the window opens.

A target with no match still runs. It is flagged amber in the table and falls back to the default account name; if that host does not have it, it fails only its own job, with a message naming the host and the account. The rest of the batch runs.

Name matching works for domain accounts too — the Password Safe lookup already falls back to domain-linked accounts — and it matches the {user};{suffix} form that cloud-native onboarding registers (the AWS Systems Manager plugin appends a scope suffix), so picking svc-ansible matches svc-ansible;local.

Connection credentials are refused for Kubernetes and database batches. Those run a localhost play that reaches out over a kubeconfig or DB login — there is no SSH connection to authenticate, and the run path silently ignores managed_account, managed_become, secret_ssh_key_source and secret_become_source. A single run can absorb that quietly; a batch would leave you believing a credential had been applied to fifty clusters, so /run-bulk rejects the combination with a 400. Named secret_vars are honored on those targets and stay available.

Databases have a per-object account of their own. A registered database already connects as the Password Safe account it was registered with, checked out just-in-time. A provisioned one connects as the stored admin by default, but if it has been onboarded into Password Safe you can switch it to its own managed account with Settings → Integrations → Password Safe → Databases tab → Ansible runs connect as the database's own managed account. That account is unique per database by construction, so a bulk run over several databases uses a different credential for each with nothing to pick.

The setting is off by default and should stay off until you have granted the account. The managed user the dashboard creates (psafe_<id>) is built with a bare login and no privileges at all — it exists to be rotated, not to read anything (on Azure SQL it only gets a contained user in master). Turn this on without granting it what your playbooks need and every run fails on permissions.

Kubernetes clusters have no equivalent: a cluster records no single managed system/account pair, so there is nothing per-cluster to select. Use named secret_vars there.


Asset types

Extension Type How the runner handles it
.yml, .yaml Ansible playbook Run as-is. Full Ansible feature surface available.
.sh Shell script Auto-wrapped: ansible.builtin.script against the target with executable: /bin/bash. The script itself runs once on the remote and exits.
.ps1 PowerShell script Auto-wrapped: ansible.windows.win_script. Targets must have ansible_connection=winrm in their inventory hostvars (Hyper-V hostvars already do this).
.rpm RPM package Auto-wrapped: copy to /tmp + ansible.builtin.dnf install.
.deb DEB package Auto-wrapped: copy to /tmp + ansible.builtin.apt install.

The auto-wrap path is a convenience for one-off operations, not a substitute for proper playbook authoring. If you find yourself writing the same .sh script three times with different targets, that's a signal to write a real .yml playbook with vars and when clauses.

Need a starting point? Ready-to-adapt Linux, Windows, Kubernetes, and database playbooks live in examples/playbooks/.


Runners

Where the Ansible process actually runs. Picked in Settings → Ansible → Runner.

Runner Where it runs Best for
Local Docker Inside the dashboard container's Docker context. Uses a side-car chrweav/ansible-winrm container per run (ansible + pywinrm). On-prem hypervisor targets; corporate-network targets; anything reachable from the dashboard host.
AWS ECS Fargate A Fargate task launched per run in your VPC. EC2 targets in private subnets without a path back to the dashboard host.
Azure ACI An Azure Container Instance per run, in your VNet. Azure VMs in private subnets.
GCP Cloud Run Jobs A Cloud Run Job per run, in your project. GCE instances.
Remote agent A one-shot container on the agent's host, inside your network. Not selectable here — it is chosen automatically when the target is only reachable that way. Which agent defaults to the one that discovered the VM, and can be routed per address range on Remote Agents → Config Routes. On-prem hypervisor guests and on-prem databases, especially from a cloud-hosted dashboard. See remote agents and which agent executes a run.

The cloud runners exist because connecting from a dashboard sitting on a corporate LAN to a deeply-private cloud subnet is often impossible without a VPN. Running the playbook inside the cloud avoids that network problem at the cost of one Fargate task / ACI / Cloud Run invocation per run.

The runner choice constrains the storage choice. The runner has to fetch the asset before executing it, which means the runner needs network reachability to the storage backend. Two combinations don't work:

Storage Runner Outcome
Local Filesystem / UNC ECS / ACI / Cloud Run Refused (the cloud runner has no path back to the corporate file server). The dashboard surfaces this as a disabled radio + 400 on the API.
Cloud bucket (S3 / Blob / GCS) Local Docker Works fine. Fetches go out over the dashboard host's normal egress.

Most teams pair Local Docker + Local Filesystem (or a cloud bucket) for on-prem labs, and one of the cloud runners + a cloud bucket for cloud fleets.

A Password Safe managed account on the ECS or Cloud Run runner needs one of two delivery paths, and the run is refused until one is on:

Both are under Settings → Ansible; how each works, and what each one trusts, is in Secrets management. ACI injects the credential inline and needs neither.

Why one-shot runners (the security argument)

Every runner — Local Docker, ECS Fargate, ACI, Cloud Run Jobs — is ephemeral by design. A new container is spawned per run; it executes the playbook; it exits and is destroyed. Nothing persists between runs.

This is deliberate, and it matters. Long-lived runners are a known weakness in CI/CD and config-management estates: they accumulate secrets in environment variables, cached SSH keys in ~/.ssh, remembered hosts in known_hosts, leftover state from previous runs. A single compromise of a long-lived runner can yield credentials that have been touched by every job that ever ran on it. The community edition's design refuses to be that target:

The compliance angle: regulations like SOC 2 CC6.1, NIST SP 800-53 AC-6 (least privilege) and SC-39 (process isolation), and CIS Controls 4.1 / 4.7 all point at "minimise persistent privileged surface". An ephemeral runner satisfies them by construction — there's nothing persistent to harden, audit, or rotate. Auditors generally accept "the runner has a 90-second lifespan and zero state at rest" with less friction than "here's our hardening baseline for the long-running worker fleet."

Compared to common alternatives:

Approach Persistent attack surface Secret-at-rest in runner Per-run isolation
Dedicated CI worker (e.g. self-hosted GitHub runner) The whole VM Cached creds, SSH known_hosts, build artefacts Best-effort cleanup scripts
Always-on Ansible Tower / AWX Platform process Vault-decrypted secrets in process memory Within the platform's job isolation
Dashboard's one-shot runner None — container is gone Tmpfs, lifetime of run Container per run; no shared FS

This isn't unique to the dashboard at the technical level — the underlying primitives (Fargate / ACI / Cloud Run Jobs / docker run --rm) have been around for years. What's notable is the design decision to only offer ephemeral runners. There's no escape hatch in the codebase for "give me a long-lived worker for performance reasons." You pay a one-second startup penalty per run; you never have to defend a fleet of long-lived runners to a security review.

The one long-lived process, and why it doesn't contradict this

Remote agents run a persistent container inside a customer's private network. That looks like the exact thing this section argues against, so it is worth being precise about why it isn't.

Read the sentence above again: no escape hatch for a long-lived worker for performance reasons. An agent is not asking for persistence to save a second of startup. It asks for it for reachability — you cannot launch a one-shot container inside a network you cannot reach, so something has to already be there. That is a different justification, and the four invariants this section actually names all survive it:

Invariant above How the agent holds it
Secrets fetched just-in-time The agent holds no target credentials at all today — discovery never authenticates
No process or filesystem outlives the run Nothing is written per job; the only persistent state is the agent's own signing key
No shared user namespace between runs Agent-executed Ansible spawns a one-shot sibling container per job; the supervisor never runs a playbook itself
Dashboard never holds the secret long Strengthened, in fact: refs are resolved on demand and the agent fetches them for the life of one child container

So the runner is still one-shot. What moved is the thing that launches it — from the dashboard's Docker socket to one inside the network the target actually lives on. The supervisor that does the launching is small, holds no credentials, and executes nothing the dashboard sends it: the job payload is a closed allowlist of scalars and network addresses, and its handler table is a closed dict rather than a dispatch on a string from the wire.


Secret scanning (advisory)

Uploaded assets are scanned for hard-coded secrets and you're warned — it's advisory: the upload always succeeds, the finding is a heads-up. The point is to catch an AWS key or a plaintext password before it's stored in the asset backend and shipped to a target.

The right fix when it fires: move the value into a vault reference (see Secrets Management) or Ansible Vault, and reference it from the playbook rather than hard-coding it.


Config-drift visibility

The Ansible stream remembers each successful apply, so you can tell when a target has drifted out of "known-good." It's passive — it records a fingerprint on a successful run; it never touches a target to check (no --check reconciler).

Fingerprints are one-way hashes — the inputs/secret values themselves are never stored.


Best practices

Stage your changes. Build a target group with one or two test hosts in it before applying to the whole fleet. The inventory is just JSON — add a test group in your hypervisor hostvars module to make this trivial.

Keep secrets out of playbooks. Anything sensitive belongs in Secrets Management, not embedded in YAML or shell scripts. Reference secrets via Ansible's lookup('env', ...), the ansible-vault integration, or fetched-at-runtime variables that the runner reads from the cloud secret store.

Use a secret without seeing it. The run form's Use a secret section injects a Secrets-Management secret (as a named var, become password, or SSH key) — or a BeyondTrust Password Safe managed account checked out just-in-time — straight into the run. The operator never sees the value; it's scrubbed from job output and the use is audited. Requires the secrets:use permission. See Using a Secrets-Management secret in a run.

Escalate through a privilege broker, not just sudo. The run form's Become method picker sets how a play escalates when it says become: true. Leave it on the default and Ansible uses sudo; pick pbrun (or pmrun) when the account is entitled through BeyondTrust Privilege Management for Unix & Linux, dzdo for Delinea/Centrify, pfexec on Solaris, and so on.

Worth setting deliberately, because the sudo failure does not name itself: an account with no sudoers entry makes sudo print its lecture and password prompt onto the module's stdout, and Ansible reports Module result deserialization failed: No start of json char found rather than "this account cannot sudo". Only the method name is ever sent — the escalation binary and its flags are not configurable from the dashboard, and the remote agent re-checks the name against its own allow-list before using it.

Available on the local runner and on remote agents. The transient cloud runners (ECS / ACI / Cloud Run) take a playbook, an SSH key and a secret channel with no plain-variable argument, so a run that sets a become method against one is refused rather than quietly run under sudo.

On a PMUL host, check what the policy actually permits. Under become, every escalated Ansible task arrives at pbrun as the same command — a BECOME-SUCCESS wrapper invoking python3 AnsiballZ_<module>.py. PBUL never sees dnf install or systemctl restart, so per-command role entitlements do not constrain an Ansible run: permitting the wrapper permits everything Ansible chooses to do. If you want PMUL to gate each action, skip become and have tasks invoke pbrun <cmd> explicitly.

examples/playbooks/linux/pmul-ansible-entitlement.yml adds the pbrun-shaped probe to a role idempotently, with a backup and a dry-run mode. Run it against the policy server, with an account that already has root — it is the play that repairs the pbrun escalation path, so it cannot rely on that path working.

The SPIRE page's build form offers the same two choices — a managed account or an SSH-key secret — reading the same /api/config-mgmt/managed-accounts and /api/config-mgmt/secret-options endpoints, refusing a run for the same reasons, and requiring the same secrets:use permission. One mental model covers both. It differs in two ways, both because a lab is one host running four fixed playbooks: the account must be pinned from that host's own list rather than matched by name, and a single checkbox reuses it as the sudo credential instead of offering a separate become picker.

Tag your runs. The Extra Vars field on the run form accepts JSON — include a run_id or a deployment ticket number so when something breaks at midnight you can grep the logs back to the playbook invocation.

Read the job logs. Every run lands in the dashboard's job tracker (/jobs). Cloud-runner runs include a CloudWatch / Azure Monitor / Cloud Logging log link. Local Docker runs include the full Ansible output inline. Drift, rollback decisions, and post-mortems live there.

Don't modify assets in place. Re-upload as a new file with a date or version stamp (hardening-base-2026-04-12.yml). The storage backend preserves both. When you're sure the new one works, delete the old via /storage.

Idempotency tests. Run the same playbook twice in a row. The second run should report 0 changed (or as close to it as your real workload allows). If the second run still does work, your "declarative" playbook is hiding procedural logic and will surprise you on partial failures.


Where this is heading on SaaS

A few things the community edition does not try to do. They're SaaS priorities — see docs/editions/comparison.md for the hosted-edition philosophy.

The ephemeral-runner property in Why one-shot runners above carries forward to SaaS — every run still gets its own single-purpose container and is destroyed after execution. The hosted edition adds tenant-scoped network isolation on top, so a run in tenant A can't reach tenant B's targets even by mistake.

You can be productive on community indefinitely with the practices in this doc. Move to SaaS when the AI assistance, tenant separation, or managed audit trail are worth more than self-hosting flexibility.


Troubleshooting

Run fails with "no active storage backend." Set up storage on /storage first; the Ansible feature flag depends on it. See storage-management.md.

Local Docker runner fails with "permission denied" mounting /var/run/docker.sock. The dashboard container needs Docker-out-of-Docker access to spawn the side-car runner. Check docker-compose.yml includes /var/run/docker.sock:/var/run/docker.sock:ro. The app runs as uid 10001 and the entrypoint gives it the socket's group automatically; if you start the container with -u / runAsUser yourself, add that group with group_add. See Container User.

Cloud runner says "AccessDenied" fetching the asset from S3. The IAM role / service principal / GCP service account the dashboard configured for VM deploys also needs read access to the storage bucket. Add the s3:GetObject / Storage Blob Data Reader / storage.objects.get permission for the storage bucket's resource path.

Hyper-V / Windows targets connection refused on PowerShell runs. The target's hostvars need ansible_connection=winrm plus the WinRM auth fields (ansible_winrm_transport, credentials). Verify by running docker compose exec app cat /tmp/inventory.json after a failed run and checking the target's hostvars block.

Run log shows the right SSH user but Permission denied (publickey). The cloud secret store probably has a stale key, or the VM's authorized_keys doesn't include it. Confirm the secret's public_key matches what's actually on the VM via cat ~/.ssh/authorized_keys.