BeyondTrust/POV

← Back to dashboard

Design: installing a Password Safe Resource Broker in a POV

Audience: contributor · Profile: pov · Read this when: you are changing how a POV installs its Password Safe Resource Broker.

Slice 5b of the POV feature — the half #648 deliberately left out. It fills PovEnvironment.ps_application_host_id.

Written before the code because the shape of this one is decided by a fact about BeyondTrust rather than about this repo, and getting that fact wrong is what a design note is for.

The fact that decides everything

A Resource Broker package comes from the tenant itself. It is generated per Password Safe tenant and downloaded from it — not a public image, not a versioned artifact this dashboard could fetch, and not something a URL in config could point at. It carries an installer key that pairs it with that tenant.

Two consequences follow immediately, and they rule out the shape slice 5 used:

So the customer has to put the package somewhere the dashboard can reach, and the dashboard has to install it without ever having chosen it. That is a staging problem, and this repo already has the machinery for it.

The shape

customer downloads RB package from their Password Safe tenant
        │
        ▼
/storage upload form                    ← already exists, with a secret scan
        │                                    (a 311 MB installer takes the CHUNKED lane:
        │                                     8 MiB parts straight into the bucket. The
        │                                     inline path — and /config-mgmt's own form —
        │                                     still caps at 64 MB. See
        │                                     docs/operations/storage-management.md#two-lanes-and-why-a-311-mb-installer-needs-the-second)
        ▼
storage backend (S3 / Azure blob / GCS)  ← a POV configures one on the wizard's
        │                                  Storage step; see §1a
        ▼
agent_ansible job on the POV's broker agent      ← already ships in agent 2.3
        │  asset      = the uploaded installer
        │  run_kind   = "vm", transport = "winrm"
        │  secret_vars = { <installer-key-var>: pov/<env>/rb_installer_key }
        │  extra_vars  = { zone: <resource zone> }
        │  login       = Skytap stored_credentials for that VM  ← §5
        ▼
ansible-winrm sibling on the LINUX broker VM
        │
        │  WinRM, across the POV's private network
        ▼
a WINDOWS VM in the POV → installer runs → RB registers with the tenant

Two special VMs, not one. The Resource Broker installer is a Windows program, so it cannot live on the broker VM — that is a Linux Docker host by construction, and slice 5 keeps the Gateway there. A POV therefore has a Linux broker (agent + Gateway) and a Windows RB host, and the broker reaches the second over WinRM.

That is not a complication; it removes one. An earlier draft of this note put the RB on the broker VM itself, which meant an Ansible run in a sibling container having to reach its own host — the ugliest part of the design, and the part most likely to be got subtly wrong. Targeting a different machine is precisely the shape agent_ansible was built for: "SSH/WinRM to a host".

Almost none of this is new. What follows is what already exists, and then the four things that do not.

What already exists

POST /api/config-mgmt/upload Takes a file into the storage backend. Runs an advisory secret scan that warns and never blocks
ansible_local_service.asset_type Classifies .ps1 as powershell, which the generator installs with win_script over WinRM. .exe and .msi are not recognised — see §1
ansible_local_service.generate_playbook_yaml Already generates a Windows play for a .ps1. The .exe/.msi wrapper does not exist yet
chrweav/ansible-winrm The run_kind="vm" runner image, which handles both SSH and WinRM. Its name is not a coincidence — this is what it is for
stored_credentials Declared in both WRITE_CONTRACT and the Skytap capability table since slice 1, and never implemented. §5 makes it real
agent_ansible Ships in agent 2.3, so unlike slice 5 this needs no agent rebuild
agent_ansible_bundle Fetches the asset, wraps it, seals it to a per-fetch key, and hands it to the agent. The sibling has no bind mounts, so the bytes travel in the bundle
RUN_META_KEYS.secret_vars Maps a var name to a source ref. The ref is what lands in the job row; the value is resolved at fetch time and never written down
RUN_META_KEYS.epml_token_var The precedent for exactly this: "the NAME of the var an EPM-L installation token is bound to, never the token"

That last row matters more than its size. EPM-L already solves "install a BeyondTrust agent, keyed to a tenant, without the key touching the database" — and the RB installer key is the same problem with a different product on the label. Slice 5b should look like it, not like something new.

1a. How the bytes actually reach the RB host

The note above used to stop at "put it in a storage backend", which skipped the two constraints that decide the whole shape. Both were found by trying it with the real artifact — BeyondTrust.Agents.Bootstrapper.exe, 314 MB.

The agent job envelope is the binding constraint, not the backend. agent_ansible_bundle fetches the asset and embeds it in a bundle capped at MAX_BUNDLE_BYTES = 256 KB, and it did so regardless of which backend the asset lived in. Putting the installer in S3 therefore did not help by itself: the run still failed at 256 KB. That is three orders of magnitude, so no ceiling adjustment reaches it.

So the asset is not carried at all. Above MAX_EMBEDDED_ASSET_BYTES (128 KB), the dashboard mints a time-limited signed URL for the object and generates a wrapper play that runs win_get_url on the Windows RB host, then win_package against the downloaded file. The bytes go from object storage straight to the target, and are never held by the dashboard, the agent, or the envelope. A 314 MB installer produces a bundle of about 1.5 KB.

The decision is made from the storage listing, before anything is read — the question is whether the file is too big to move through the dashboard, and answering it by moving the file through the dashboard would defeat the point.

Three consequences worth knowing:

1. What the installer actually is, and the .exe asset-type gap

Grounded in Resource Broker How to (BeyondInsight space) rather than inferred. The artifact is BeyondTrust.Agents.Bootstrapper.exe, and it takes MSI-style NAME=value arguments:

INSTALLKEY Required for a silent install, or the user is prompted. The per-tenant key
ZONE Also required for a silent install. The resource zone this broker joins
/quiet Makes it silent at all
-l <path> The full install log. Give it an absolute path — see below
INSTALLPATH Optional
RESTART Optional — and see §2 before touching it
USEPROXY / PROXYADDRESS / PROXYPORT Optional; out of scope for this slice

So a silent install is:

BeyondTrust.Agents.Bootstrapper.exe /quiet -l "C:\Windows\Temp\beyondtrust-rb-install.log" INSTALLKEY=<key> ZONE=<zone>

That settles the question this note previously left open: the key is an argument, not a file the installer expects beside itself. No win_copy and no hand-written playbook.

The log path is absolute, and the play reads it back

This started as -l "install.log". A relative path is resolved against whatever working directory win_package happened to launch the process with — not one this play chooses or an operator can predict — so the file landed somewhere nobody could name.

That matters more than it sounds, because an MSI-backed installer reports failure as a bare exit code. A live install returned:

"rc": 1603, "stdout": "", "stderr": "", "reboot_required": false

1603 is ERROR_INSTALL_FAILURE — "fatal error during installation" and nothing more. It is the installer's way of saying look in the log, and the log was the one thing the run had thrown away.

So: pov_resource_broker.RB_INSTALL_LOG is an absolute path under C:\Windows\Temp, passed twice — into -l so the installer writes there, and as the installer_log_path extra var so the play knows where to read it back from. A rescue: on the install task reads the last 120 lines, prints them to Live Output, and then fails with the installer's own exit code. Reading a log is not tolerating the error.

C:\Windows\Temp rather than the connecting user's %TEMP%: the run arrives over WinRM as an administrator whose profile temp resolves to an 8.3 path (C:\Users\ADMINI~1\AppData\Local\Temp), and an MSI-backed installer hands the real work to msiexec running as SYSTEM. A machine-wide directory is readable by both — and by whoever logs on afterwards to look, which on a POV may mean going through the very Gateway this install was supposed to provide.

If a run still fails with 1603 and the log says nothing useful, the log is now on the target at that path and survives the run; the downloaded installer no longer does, because the cleanup moved into an always.

The gap

_EXT_TYPE knows .yml, .sh, .ps1, .rpm and .deb, and anything else falls through to "playbook". So a .exe uploaded today is handed to ansible-playbook as though it were YAML and fails with "Unable to parse … did not contain a list of plays" — which reads as a broken playbook rather than as a file that was never one.

Two ways out, and the first is better:

ZONE is a second per-POV value, and it is not a secret

This is the parameter most likely to be missed, because "the installer needs a key" is the half everyone remembers. A silent install without ZONE prompts — and a prompt in an unattended run is not an error, it is a process that sits there until the run's timeout kills it, with an install log that ends mid-dialog.

It is a zone name, not a credential, so it belongs in plain extra_vars alongside the key's secret_vars entry. Storing it on the POV row (or in the environment's extra_data) is enough; it does not need the encrypted per-resource treatment the key gets.

Refuse to queue a run with no zone, in preflight, with the same discipline slice 5 applies to a missing deploy key. The alternative is a job that hangs for its full timeout and reports nothing useful.

2. The installer key is a secret_vars entry, not a command-line parameter

The obvious reading of "pass the installer key as a command-line parameter" is ansible-playbook -e key=…, and it should not be built that way: argv is visible in ps for the life of the process, on a host inside a customer's environment.

Note this is about how the key reaches Ansible, not about how it reaches the installer. Handing it to win_package's arguments: is fine and is what the product expects; what must not happen is the value appearing on the runner's own command line, where every process on that host can read it.

Do not set RESTART=1

Worth its own heading because the documentation's phrasing is easy to skim past: during a silent install, RESTART=1 restarts the machine automatically, with no prompt. That would drop the WinRM session mid-play, and Ansible would report the failure of a step that had in fact succeeded — the worst kind of wrong answer, because retrying reinstalls a working broker.

If the RB genuinely needs a reboot, the play should use ansible.windows.win_reboot, which waits for the host to come back and continues. Leave RESTART unset and let the play own the reboot.

secret_vars gets the same result without that. It maps a var name to a source ref; the job row holds the ref, ansible_credentials.resolve turns it into a value at the moment the agent fetches the sealed bundle, and the runner receives it as a variable. It is registered for redaction, so it cannot appear in Live Output either.

The key is stored the same way the Gateway deploy key is — pov/<env_id>/rb_installer_key, Fernet-encrypted, resolvable from an external vault by reference, carried by the config-migration tool.

3. Which VM is the RB host, and how it is chosen

PovEnvironmentVM already records os_family — "linux", "windows" or blank — and slice 3 was deliberate that blank means unknown, never a guess, "because a confident wrong answer sends a Windows VM down the Password-Safe-over-SSH path". That column is what makes the RB host selectable at all.

Selection is an operator-chosen name, matched exactly and case-insensitively, defaulting to something like rb. Not "the first Windows VM" — a POV template with a domain controller and a member server has two, and picking one by position installs a Resource Broker on whichever the platform happened to list first.

This no longer matches the broker VM's rule, and that is deliberate. The broker is now auto-detected as the only Linux VM when no name is typed (pov_broker.resolve_broker_candidate). The RB host keeps the name-only rule because the premise that unlocked inference does not hold for it: a POV template almost always has several Windows guests — a domain controller and a member server at least — so there is no "the only one" to find. Uniqueness is what makes inference safe, not the OS check; where uniqueness is absent, a name is the only honest answer. The Entitle agent host keeps the name-only rule for the mirror-image reason: it competes with the broker for the same Linux guests, which is why claimed_vm_names excludes it from the broker's inference rather than letting the two guess at each other.

The run is then run_kind="vm", transport="winrm", target_host = that VM's private_ip, target_port 5985 or 5986. agent_ansible_meta.transport_for_guest_os already derives WinRM from a Windows guest, so the transport does not have to be typed.

Refuse, loudly, if the named VM's os_family is not windows. A Linux VM reached over WinRM fails as a connection timeout, which reads as a firewall problem and sends an SE to the wrong place entirely.

4. It widens what a POV broker may do, and that should be visible

Slice 3 generates the broker's policy.yaml, and slice 5 added agent_gateway to it. This adds agent_ansible, which is a materially larger grant — the policy example says so in as many words:

targets: at the top of this file grants a port PROBE. This grants a playbook, running as root, on the hosts you name.

The ansible: block also needs its own targets: list, deliberately separate from the discovery one. For a POV that list should be the RB host's /32 on 5985/5986 and nothing else — not the POV's whole VM list, and not the broker's own address. The RB installs on one machine; granting playbook execution against every VM in the environment because one of them needs an installer is the kind of widening nobody notices until it matters.

It also needs ansible.vm_image (chrweav/ansible-winrm), because run_kind="vm" selects it — and the operator has to have pulled it onto the broker VM, because the agent will not pull an image for you.

And, as with slice 5: a POV brokered before 5b has none of this, so the same re-broker remedy applies and the preflight should say so by name rather than letting the job lease and be refused.

5. The credential comes from the platform: stored_credentials

Decided. A WinRM run needs a Windows login, and it comes from Skytap's own stored credentials — lab_platforms.CAPABILITIES["skytap"]["stored_credentials"] is already declared True, stored_credentials is already in WRITE_CONTRACT as (env_id, vm_id) -> [{text, notes}], and it has never been implemented. Slice 5b makes it real.

It is the right source for three reasons. It adds no new secret to this database — the value is read from the platform per run and never stored. It is per-environment by construction, so two POVs from the same template do not share a credential row. And a template's Windows VM already has a baked-in local administrator recorded there, because that is what the field is for.

The two alternatives, and why not:

A per-POV credential the operator supplies Works, and is the fallback for a platform whose capability table says stored_credentials: False. As the primary path it is one more secret in this database and one more thing to paste per POV, for a value the platform already holds
Password Safe itself managed_account already exists in RUN_META_KEYS and checks a credential out just-in-time, rotating it on check-in. Elegant, and circular: the RB is what gives Password Safe reach into this network, so it cannot be the source of the credential that installs it

The wrinkle: it is free text, not a username and a password

This is the part to get right, because the contract's shape gives it away — [{text, notes}], not [{username, password}]. Skytap stores what somebody typed into a box. In practice that is administrator / Password123, or administrator:Password123, or a sentence with the pair somewhere inside it.

So the adapter has to parse, and parsing is where this goes wrong quietly. Three rules:

Locality is second rather than first on purpose. What the install needs is privilege — an RB installed as a service account fails in ways that do not say wrong account. What it prefers is a login that does not depend on a domain controller having booted, because in a lab whose boot order is not guaranteed that failure reaches WinRM as an authentication error and reads as a bad password. A domain-qualified win is logged at WARNING for exactly that reason.

Position in the list is still never used. It is still a guess, and this consumer seals a single credential into a run bundle the agent uses, so it never authenticates and would never discover the wrong one was chosen.

The template builder's SSH install needs none of this: it authenticates in process, so it takes the whole list (candidates rather than pick) and tries each until one is accepted. It gets the same ranking as an order to try them in — a stable sort, so entries the rule cannot separate keep the platform's order — because the first attempt is the one that usually wins and three failed authentications is three lines an SE reads past. Ambiguity a round trip can settle is not ambiguity. It still refuses when none parse, in the same words, and it fails at once when the guest answers and rejects them all — the retry ladder there is for a guest whose sshd is not up yet, and a rejected password is not that. * Never log the text. It contains the password by definition. The parsed username is fine to name in a job log — which is what lets the ambiguity refusal above list the logins it could not choose between, and what lets a successful pick record the one it took. Nothing else from that field is.

The parsed pair maps onto the run as login_user plus a resolved password. Note the password must reach ansible_credentials as a value at bundle-assembly time rather than a ref — it is fetched from the platform, not stored — so this is the one credential in the Config-Management path that does not begin life as a secret_vars source. Worth a look during implementation: it may be cleanest to mint it into the per-POV config space just long enough for the fetch, or to widen the resolver by one typed field.

On a platform that stores none

AWS, GCP and OCI declare stored_credentials: False, so the fallback in the table above is what runs there: the operator sets the guest's username in the Login column and a password beside it. The password is stored at pov/<env>/vm/<vm>/login_password, encrypted like the installer key, and platform_login returns that pair instead of reading the platform. login_problem refuses a missing one at preflight — in the Resource Broker, Entitle agent and guest-step paths alike — rather than at the bundle fetch, where it used to surface as a failed run. On Skytap and Azure a password sent to that endpoint is refused: the rule there is still "read it live, store nothing".

WinRM also has to be reachable and enabled on that guest, which is a template-contract question rather than a dashboard one — see §7.

6. What ps_application_host_id actually holds — RESOLVED

This section used to guess that the column held a Resource Broker id, to be read back after the install "by listing brokers via ps_api_service". Both halves of that were wrong, and the guess cost the feature: pov_wireup.ps_context refused the entire Password Safe half without the column, and no code path ever wrote it, so every POV ever created skipped Password Safe onboarding with a message about a missing Resource Broker that was in fact installed.

It is not the broker handle. application_host_id is a managed-system attribute naming another managed system that carries IsApplicationHost — the provider emits the pair, application_host_id alongside is_application_host = false. What makes Password Safe reach a private address is the broker's resource zone and the workgroup mapped to it, which is BeyondInsight configuration this dashboard performs no part of.

Three pieces of evidence, none of which needed a live tenant:

What shipped instead:

The original instinct in this section was still right about one thing: a column filled with a guess is worse than an empty one. The mistake was gating on it.

Still unverified

Whether setting an override changes anything observable. If it does not, that is the expected outcome. A cheap probe would settle the mechanism outright: ps_api_service._workgroup_id already reads GET Workgroups and discards every field but the id — one temporary log of a row's keys shows whether a resource-zone id is present, which would make the zone-to-workgroup mapping something this dashboard could verify rather than merely name.

7. The template contract grows a second VM

Slice 3 documented what a POV template's broker VM must carry: Docker, an automatic network, and the metadata runner. This adds a second machine to that contract, and it should be written into docs/profiles/pov/skytap.md beside the first rather than discovered:

None of that is exotic for a POV template — it is what a Password Safe demo needs anyway. It is worth stating because a missing piece of it fails as a WinRM timeout, and a WinRM timeout looks like a network problem no matter which of the three is actually absent.

One sizing note. The bootstrapper is a bundle: it installs the VC++ 2010 and 2015-2019 redistributables, .NET Framework 4.7.2 and the .NET Core hosting bundle before it installs the broker itself. That is minutes, not seconds, and often a fresh download on a clean template. ansible.max_runtime_minutes defaults to 30 in the policy example, which is enough — but the job's own expectations should match, and an operator watching Live Output should be told what the silence is.

What this is not

Not a new agent verb. Slice 5 needed one and paid an image-rebuild gate for it. agent_ansible already ships, which means 5b can land without one — and that is worth protecting. If the design starts drifting toward "a small handler that just runs the installer", that is the moment to stop and reconsider, because it doubles the deployment cost of the feature for convenience.

Not an RB the dashboard versions or upgrades. It installs what the customer staged. Replacing it means uploading a newer package and running again; there is no upgrade path to write and no version to track.

Not a Linux Resource Broker. The installer is a Windows program, so the Linux broker VM is not a candidate however convenient it would be. Slice 5's Gateway stays on the Linux broker; the two live on different machines on purpose.

Slice boundary

In:

Out:

Sources

The product facts above are from BeyondTrust's own documentation rather than from reading this repo, which is the whole reason this note exists:

Decisions taken

Recorded so a reader does not re-open them:

The RB host is a separate Windows VM, not the broker The installer is a Windows program; the Gateway stays on the Linux broker
The credential comes from Skytap stored_credentials §5. No new secret in this database, per-environment by construction
The installer key rides secret_vars, the zone rides extra_vars §1, §2. One is a credential, one is not
Delivery is agent_ansible, not a new agent verb It already ships in agent 2.3, so no image-rebuild gate

Prerequisite

Slices 3, 4 and 5 have never been run against live infrastructure — no Skytap account, no PRA appliance, no Password Safe tenant. 5b puts a fourth untested layer on top and its first real test would involve a customer's tenant. Validating the stack below it first is the cheaper order.