Skip to content

feat(pam launch): add just-in-time (JIT) access via -j/--jit (ephemeral + elevation)#1979

Merged
sk-keeper merged 1 commit intoKeeper-Security:releasefrom
msawczynk:feat/pam-launch-jit
Apr 23, 2026
Merged

feat(pam launch): add just-in-time (JIT) access via -j/--jit (ephemeral + elevation)#1979
sk-keeper merged 1 commit intoKeeper-Security:releasefrom
msawczynk:feat/pam-launch-jit

Conversation

@msawczynk
Copy link
Copy Markdown
Contributor

Summary

Adds opt-in just-in-time (JIT) provisioning to pam launch, covering both
flavors defined by the keeper-pam-declarative schema:

  • Ephemeral account (create_ephemeral: true) — Gateway creates a short-lived
    account on the target for the session and deletes it on disconnect.
  • Privilege elevation (elevate: true) — Gateway adds the linked credential
    to an elevated group/role for the session and reverts on disconnect.
  • Both combined — ephemeral account is provisioned and immediately elevated.

Commander now reads pamSettings.options.jit_settings off the pamMachine record
and, when the operator passes -j/--jit, emits a new credentialType='ephemeral'
to the Gateway (for ephemeral/both) alongside optional jitSettings and
jitElevation payloads that mirror the declarative schema keys verbatim. The
guacd connection settings leave username/password empty in ephemeral mode so the
Gateway's server-side credential injection wins.

This replaces the # TODO: Add JIT placeholder left in
keepercommander/commands/pam_launch/launch.py.

Motivation

The Keeper PAM declarative layer and pam-environment JSON schema already define
pamSettings.options.jit_settings (see
keeper-pam-declarative/manifests/pam-environment.v1.schema.json
$defs.jit_settings), and the Gateway already supports the ephemeral credential
type. The missing piece was the Commander client: it previously ignored
jit_settings entirely and the pam launch command had an explicit # TODO: Add JIT comment. This PR wires it end-to-end with no changes to the Gateway.

Precedence (matches Web Vault)

allowSupplyHost > JIT > allowSupplyUser > linked:

  • -j is rejected on records with allowSupplyHost (clear error).
  • -j is mutually exclusive with -cr, -H, -hr — JIT provisions the
    credential itself.
  • ephemeral_account_type=='domain' requires pam_directory_uid_ref on the
    record, matching the declarative validator.
  • Records that happen to have jit_settings never auto-trigger JIT unless -j
    is passed — strictly opt-in, fully backward compatible.

Wire contract

The gateway inputs dict gains three optional keys:

mode credentialType extra payload
ephemeral ephemeral jitSettings = {create_ephemeral, ephemeral_account_type, base_distinguished_name?, pam_directory_uid_ref?}
elevation linked + userRecordUid jitElevation = {elevate, elevation_method, elevation_string}
both ephemeral jitSettings and jitElevation

Key names mirror the declarative schema verbatim (_JIT_EPHEMERAL_KEYS /
_JIT_ELEVATION_KEYS) so adapting to any future camelCase switch on the Gateway
side is a one-function edit to the two builder helpers in
terminal_connection.py.

Files changed

  • keepercommander/commands/pam_launch/launch.py--jit / -j flag,
    validation, _get_jit_settings, _derive_jit_mode; removes the
    # TODO: Add JIT comment.
  • keepercommander/commands/pam_launch/terminal_connection.py — read
    jit_settings in extract_terminal_settings; thread jit_enabled/jit_mode
    through create_connection_context; dispatch new credentialType='ephemeral'
    branch in the offer builder; leave guacd creds empty when ephemeral;
    _build_jit_ephemeral_payload / _build_jit_elevation_payload projectors.
  • keepercommander/commands/pam_launch/README.md — new, documents the flag,
    modes, precedence, and gateway compatibility.
  • unit-tests/pam/test_pam_launch_jit.py — 20 new unit tests.

Usage

# Ephemeral Linux SSH account
keeper pam launch my-linux-host -j

# Ephemeral domain account (Windows host joined to AD)
keeper pam launch prod/win-rdp -j

# Privilege elevation against an existing linked account
keeper pam launch db01 -j

Record shape:

pam_settings:
  options:
    jit_settings:
      create_ephemeral: true
      ephemeral_account_type: linux      # linux | mac | windows | domain
  connection:
    protocol: ssh
    administrative_credentials_uid_ref: <admin-record-uid>

Test plan

  • 20 new unit tests in unit-tests/pam/test_pam_launch_jit.py covering
    payload builders, jit_mode derivation, record extraction, and the
    ephemeral/elevation disjointness invariant.
  • Full PAM suite (pytest unit-tests/pam/) — 123 passed (103 pre-existing
    • 20 new) both before and after rebase onto latest release.
  • ReadLints clean on all edited files.
  • Live smoke test against the acme lab (Lab Rocky — Terraform Training (SSH),
    UID CzYUVX3cW3YOkEu2Jt4bQA): temporarily flipped
    pamSettings.options.jit_settings through all three modes and captured the
    exact inputs dict Commander would POST to the Gateway before short-circuiting,
    then restored the original record state in a try/finally. Captured payloads
    below.

Captured wire payloads (live acme lab)

Scenario 1 — ephemeral linux:

{
  "recordUid": "CzYUVX3cW3YOkEu2Jt4bQA",
  "kind": "start",
  "conversationType": "ssh",
  "trickleICE": true,
  "credentialType": "ephemeral",
  "jitSettings": {"create_ephemeral": true, "ephemeral_account_type": "linux"}
}

Scenario 2 — elevation group:

{
  "recordUid": "CzYUVX3cW3YOkEu2Jt4bQA",
  "kind": "start",
  "conversationType": "ssh",
  "trickleICE": true,
  "credentialType": "linked",
  "userRecordUid": "Q_luxs_ofKp-zQes7A173A",
  "jitElevation": {"elevate": true, "elevation_method": "group", "elevation_string": "wheel,sudo"}
}

Scenario 3 — ephemeral + elevation:

{
  "recordUid": "CzYUVX3cW3YOkEu2Jt4bQA",
  "kind": "start",
  "conversationType": "ssh",
  "trickleICE": true,
  "credentialType": "ephemeral",
  "jitSettings": {"create_ephemeral": true, "ephemeral_account_type": "linux"},
  "jitElevation": {"elevate": true, "elevation_method": "group", "elevation_string": "wheel"}
}

Backward compatibility

  • --jit is optional; without it, pam launch behaviour is byte-identical to
    current release.
  • Records that carry jit_settings but are launched without -j continue to use
    their linked credential / allowSupplyUser / allowSupplyHost path exactly as
    before.
  • No new external dependencies.
  • No changes to existing arguments on PAMLaunchCommand.parser.

Notes

Made with Cursor

@msawczynk
Copy link
Copy Markdown
Contributor Author

@craiglurey @miroberts

@msawczynk msawczynk force-pushed the feat/pam-launch-jit branch from 67b6ba6 to 507fb85 Compare April 22, 2026 16:11
@msawczynk
Copy link
Copy Markdown
Contributor Author

@sergeyk — both issues addressed and force-pushed to 43e36d7.

Python 3.7 breakage — the JIT helpers (_build_jit_ephemeral_payload,
_build_jit_elevation_payload, _derive_jit_mode, _get_jit_settings) are
imported inside if sys.version_info >= (3, 8): because pam_launch's
transitive WebRTC / Guacamole deps require 3.8+. In the previous revision the
test classes lived outside that guard, so on 3.7 they loaded but then hit
NameError on every reference. Fixed by indenting the classes and helper
stubs under the same version guard (matches the pattern in
test_pam_tunnel.py). The file now imports cleanly on 3.7 and all 20 tests
run on 3.8+.

Merge conflicts — rebased onto current release tip (0b9aa0e9).
The conflicts were with #1871 (Connect As options) and #1981 (TunnelDAG
caching adding a tdag kwarg to _get_launch_credential_uid); both resolved
to keep their new signatures/flags alongside the new JIT flag and helpers.

CI now green on both 3.7 and 3.12. PR is a single clean commit on top of
release and mergeable: MERGEABLE.

@msawczynk msawczynk force-pushed the feat/pam-launch-jit branch from 43e36d7 to c11930c Compare April 23, 2026 11:37
Adds opt-in JIT provisioning to `pam launch` in both flavours supported by
the Gateway:

- Ephemeral account (create_ephemeral=true): Gateway creates a short-lived
  account on the target for the session and deletes it on disconnect.
- Privilege elevation (elevate=true): Gateway adds the linked credential to
  an elevated group/role for the session and reverts on disconnect.
- Both combined: ephemeral account provisioned *and* immediately elevated.

When `-j/--jit` is passed, Commander emits `credentialType: 'ephemeral'`
to the Gateway (for ephemeral/both) alongside optional `jitSettings` /
`jitElevation` payloads. In ephemeral mode the guacd connection leaves
username/password empty so the Gateway's server-side credential injection
wins.

Two authoring paths are supported uniformly:

1. Web Vault UI (authoritative) — encrypted DATA edge with path
   `jit_settings` on the resource vertex in the DAG. Keys are camelCase
   (createEphemeral, elevate, elevationMethod, elevationString,
   baseDistinguishedName, ephemeralAccountType), matching
   DagJitSettingsObject.to_dag_dict.
2. Declarative (`pam env apply`) — snake_case block under
   pamSettings.options.jit_settings on the record's typed field.

All JIT shape handling (loader, normaliser, mode derivation, payload
projection, credential-check predicate) is centralised in
keepercommander/commands/pam_launch/jit.py so the wire contract has
exactly one place to change if the Gateway flips casing or adds a field.
`launch.py` and `terminal_connection.py` import the public API from that
module directly; no JIT logic is duplicated.

Precedence matches Web Vault: allowSupplyHost > JIT > allowSupplyUser >
linked. `-j` is rejected on records with allowSupplyHost and is mutually
exclusive with `-cr/-H/-hr`.

Credential requirements follow the mode:
- Ephemeral / both: Gateway provisions the account, so the pre-flight
  linked-credential check is bypassed (jit.provisions_credential predicate).
- Elevation: linked credential is still required; a clear error is raised
  if the record has none.
- Domain ephemeral (ephemeralAccountType=domain) binds to a pamDirectory
  via a separate DAG LINK edge (path="domain"); the Gateway owns that
  validation — Commander does not re-check it.

JIT is strictly opt-in: records that happen to have jit_settings never
auto-trigger JIT unless `-j` is passed, preserving backward compatibility.

Tested:
- 32 unit tests in unit-tests/pam/test_pam_launch_jit.py covering the
  normaliser (camelCase/snake_case/collision/unknown-key), mode derivation,
  payload builders (key isolation, empty-value dropping, disjointness),
  the DAG-first loader, and the credential-bypass predicate.
- All 135 PAM unit tests pass.
- End-to-end validation against the Acme lab (Lab Rocky pamMachine)
  exercised all three JIT modes (ephemeral, elevation, both) by writing
  camelCase JIT settings to the DAG (mirroring Web Vault), then calling
  detect_protocol, extract_terminal_settings, and the jit payload builders
  to verify the `credentialType` and `jitSettings`/`jitElevation` dict
  Commander POSTs to the Gateway. Lab state snapshotted and restored in a
  try/finally.
- Reproduced `pam launch --jit` on a freshly gateway-linked lab record
  with no linked credential (the failure mode Michael reported) and
  confirmed the credential pre-flight is now bypassed for ephemeral/both.

Made-with: Cursor
@msawczynk msawczynk force-pushed the feat/pam-launch-jit branch from c11930c to bd12de1 Compare April 23, 2026 13:08
@sk-keeper sk-keeper merged commit ccb7061 into Keeper-Security:release Apr 23, 2026
4 checks passed
@msawczynk msawczynk deleted the feat/pam-launch-jit branch April 24, 2026 16:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants