From bd12de1f64a43da38bc8688a91d032a15dc3b15a Mon Sep 17 00:00:00 2001 From: Martin Sawczynski Date: Wed, 22 Apr 2026 16:22:31 +0100 Subject: [PATCH] feat(pam launch): add just-in-time (JIT) access via -j/--jit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- keepercommander/commands/pam_launch/README.md | 105 +++++++ keepercommander/commands/pam_launch/jit.py | 228 +++++++++++++++ keepercommander/commands/pam_launch/launch.py | 101 ++++++- .../pam_launch/terminal_connection.py | 117 +++++++- unit-tests/pam/test_pam_launch_jit.py | 276 ++++++++++++++++++ 5 files changed, 803 insertions(+), 24 deletions(-) create mode 100644 keepercommander/commands/pam_launch/README.md create mode 100644 keepercommander/commands/pam_launch/jit.py create mode 100644 unit-tests/pam/test_pam_launch_jit.py diff --git a/keepercommander/commands/pam_launch/README.md b/keepercommander/commands/pam_launch/README.md new file mode 100644 index 000000000..7cdc14ba4 --- /dev/null +++ b/keepercommander/commands/pam_launch/README.md @@ -0,0 +1,105 @@ +# `pam launch` + +Launch a terminal connection to a PAM resource (`pamMachine`, `pamDirectory`, `pamDatabase`) +over SSH, Telnet, Kubernetes, MySQL, PostgreSQL, or SQL Server. The Gateway brokers +the connection via WebRTC + Guacamole; Commander only drives the client side. + +``` +pam launch + [-cr|--credential RECORD] + [-H|--host HOST:PORT] + [-hr|--host-record RECORD] + [-j|--jit] + [-nti|--no-trickle-ice] +``` + +## Credential modes + +Commander negotiates one of four credential modes with the Gateway: + +| Mode | When | How the credential is obtained | +|---|---|---| +| `linked` | Record has a DAG-linked `pamUser` | Gateway looks up the credential via DAG | +| `userSupplied` | `allowSupplyUser`/`allowSupplyHost` + `-cr` | Commander encrypts a `ConnectAs` payload to the Gateway's public key | +| `ephemeral` | `-j/--jit` + JIT enabled on the record (Web Vault UI toggle or `pam env apply`) | Gateway creates a short-lived account for the session, tears it down afterwards | +| *(unset)* | Fallback | Gateway reads login/password directly off the `pamMachine` record | + +## Just-in-time (JIT) access — `-j / --jit` + +Commander supports the two JIT flavors: + +- **Ephemeral account** (`create_ephemeral: true`) — Gateway creates a fresh account on the + target, optionally joining a domain, and deletes it on disconnect. +- **Privilege elevation** (`elevate: true`) — Gateway adds the launch credential to an + elevated group/role for the session and reverts afterwards. Commander still emits the + linked credential so the Gateway knows who to elevate. +- **Both** (`create_ephemeral: true` + `elevate: true`) — ephemeral account is provisioned + *and* immediately elevated. + +### Where JIT settings live + +Two supported authoring paths, both consumed uniformly by `pam launch`: + +1. **Web Vault UI (authoritative)** — the UI writes an encrypted DATA edge + with path `jit_settings` on the resource vertex in the DAG. Keys are + camelCase (`createEphemeral`, `elevate`, `elevationMethod`, `elevationString`, + `baseDistinguishedName`, `ephemeralAccountType`). This is how Michael's record + stores its JIT toggle. +2. **Declarative (`pam env apply`)** — mirrors the block under + `pamSettings.options.jit_settings` on the record's typed field in snake_case + (see `keeper-pam-declarative/manifests/pam-environment.v1.schema.json` → + `$defs.jit_settings`). + +`pam launch` prefers the DAG when present and falls back to the typed-field +mirror otherwise. All loading, normalisation (camelCase → snake_case), mode +derivation, and gateway-payload projection is centralised in +`keepercommander/commands/pam_launch/jit.py` so there is exactly one place to +change if the wire format evolves. + +Minimal declarative shape: + +```yaml +pam_settings: + options: + jit_settings: + create_ephemeral: true + ephemeral_account_type: linux # linux | mac | windows | domain + connection: + protocol: ssh + administrative_credentials_uid_ref: +``` + +For `ephemeral_account_type: domain` the record **must** also carry +`pam_directory_uid_ref`; Commander rejects the launch otherwise, matching the +declarative validator. + +### CLI examples + +```bash +# 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 +``` + +### Precedence rules (match Web Vault) + +1. `allowSupplyHost` wins over JIT. `pam launch -j` on a record with + `allowSupplyHost: true` is rejected with a clear error — supply host+credential + manually via `-H` / `-hr` / `-cr` instead. +2. `-j` is mutually exclusive with `-cr`, `-H`, and `-hr`. JIT provisions the + credential itself, so overriding credential or host alongside `-j` is rejected. +3. A record that has `jit_settings` but is launched **without** `-j` behaves exactly + as before — JIT is strictly opt-in. + +### Gateway compatibility + +JIT uses the Gateway's existing `credentialType` protocol extended with the `ephemeral` +value plus two optional payload blocks: `jitSettings` (ephemeral metadata) and +`jitElevation` (elevation deltas). Keys are snake_case on the wire and are built by +`jit.build_ephemeral_payload` / `jit.build_elevation_payload`, so adapting to any +future camelCase-vs-snake_case change is a one-function edit in `jit.py`. diff --git a/keepercommander/commands/pam_launch/jit.py b/keepercommander/commands/pam_launch/jit.py new file mode 100644 index 000000000..634f9a9ef --- /dev/null +++ b/keepercommander/commands/pam_launch/jit.py @@ -0,0 +1,228 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Optional[Dict[str, Any]]: + """Accept DAG camelCase or declarative snake_case; return snake_case dict. + + Two-pass so snake_case always wins on the (pathological) collision where a + caller supplies both casings for the same field — that's the shape the rest + of ``pam_launch`` expects. Returns ``None`` for non-dict or empty inputs. + """ + if not isinstance(raw, dict): + return None + out = {_CAMEL_TO_SNAKE[k]: v for k, v in raw.items() if k in _CAMEL_TO_SNAKE} + out.update((k, v) for k, v in raw.items() if k not in _CAMEL_TO_SNAKE) + return out or None + + +# ---- Mode derivation ------------------------------------------------------ + +def derive_jit_mode(jit_settings: Optional[Dict[str, Any]]) -> Optional[str]: + """Return one of :data:`JIT_MODE_*` or ``None`` for a normalised JIT dict.""" + if not isinstance(jit_settings, dict): + return None + create_ephemeral = bool(jit_settings.get('create_ephemeral')) + elevate = bool(jit_settings.get('elevate')) + if create_ephemeral and elevate: + return JIT_MODE_BOTH + if create_ephemeral: + return JIT_MODE_EPHEMERAL + if elevate: + return JIT_MODE_ELEVATION + return None + + +# ---- Credential semantics ------------------------------------------------- + +def provisions_credential(jit_flag: bool, jit_mode: Optional[str]) -> bool: + """True when an active --jit launch will get its credential from the gateway. + + In ``ephemeral`` and ``both`` modes the Gateway provisions a short-lived + account, so pre-existing credentials on the record are not required. + ``elevation`` mode still needs a linked credential (elevation is applied on + top of it) and therefore returns ``False``. When ``--jit`` was not passed + this predicate is always ``False`` — normal credential validation applies. + """ + return bool(jit_flag) and jit_mode in (JIT_MODE_EPHEMERAL, JIT_MODE_BOTH) + + +# ---- Loader --------------------------------------------------------------- + +def _typed_field_jit_settings(record: Any) -> Optional[Dict[str, Any]]: + """Read jit_settings from the pamSettings typed field (declarative mirror).""" + if not record: + return None + get_field = getattr(record, 'get_typed_field', None) + if not callable(get_field): + return None + field = get_field('pamSettings') + if field is None or not hasattr(field, 'get_default_value'): + return None + value = field.get_default_value(dict) + if not isinstance(value, dict): + return None + options = value.get('options') + if not isinstance(options, dict): + return None + return normalize_jit_settings(options.get('jit_settings')) + + +def _dag_jit_settings(params: Any, record_uid: Optional[str]) -> Optional[Dict[str, Any]]: + """Read jit_settings from the DAG (Web Vault authoritative storage).""" + if params is None or not record_uid: + return None + # Lazy import — commands/pam_import pulls DAG / protobuf dependencies that + # we don't want to import on every pam launch, only when JIT is in play. + from ..pam_import.keeper_ai_settings import get_resource_jit_settings + try: + raw = get_resource_jit_settings(params, record_uid) + except Exception as exc: # pragma: no cover - defensive + logging.debug('pam launch: DAG jit_settings lookup failed for %s (%s)', + record_uid, exc) + return None + return normalize_jit_settings(raw) + + +def load_jit_settings( + params: Any = None, + record: Any = None, + record_uid: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + """Return the snake_case JIT settings for a record, preferring the DAG. + + Lookup order: + 1. DAG DATA edge ``jit_settings`` on the resource vertex (Web Vault UI). + 2. ``pamSettings.options.jit_settings`` on the record's typed field + (``pam env apply`` mirror). + + Either ``record`` or ``record_uid`` is sufficient; providing both lets the + function reach both storage locations in one call. + """ + uid = record_uid or getattr(record, 'record_uid', None) + dag = _dag_jit_settings(params, uid) + if dag: + return dag + return _typed_field_jit_settings(record) + + +# ---- Gateway wire-format payloads ----------------------------------------- + +def _project(jit_settings: Any, keys) -> Dict[str, Any]: + """Return ``{k: v for k in keys if v is not empty}``. Internal.""" + if not isinstance(jit_settings, dict): + return {} + payload: Dict[str, Any] = {} + for k in keys: + v = jit_settings.get(k) + if v in (None, ''): + continue + payload[k] = v + return payload + + +def build_ephemeral_payload(jit_settings: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Project the ephemeral-relevant subset for the gateway ``jitSettings`` field. + + Empty / ``None`` values are dropped so the payload stays minimal. Key names + match the declarative schema verbatim (snake_case); if the gateway ever + requires camelCase on the wire, this function is the one place to change. + """ + return _project(jit_settings, _EPHEMERAL_KEYS) + + +def build_elevation_payload(jit_settings: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Project the elevation-relevant subset for the gateway ``jitElevation`` field.""" + return _project(jit_settings, _ELEVATION_KEYS) diff --git a/keepercommander/commands/pam_launch/launch.py b/keepercommander/commands/pam_launch/launch.py index e61bba921..a68a146b7 100644 --- a/keepercommander/commands/pam_launch/launch.py +++ b/keepercommander/commands/pam_launch/launch.py @@ -271,6 +271,15 @@ def _record_has_host_port(record: Any) -> bool: return bool(host) and port is not None +# JIT storage and shape handling is centralised in ``jit.py``. +from .jit import ( + JIT_MODE_ELEVATION, + derive_jit_mode, + load_jit_settings, + provisions_credential, +) + + class PAMLaunchCommand(Command): """PAM Launch command to launch a connection to a PAM resource""" @@ -305,6 +314,12 @@ class PAMLaunchCommand(Command): parser.add_argument('--scale', '-s', required=False, dest='scale', type=int, default=None, help='Scale pixel width/height by this percentage (e.g. 50 = half canvas, 200 = double). ' 'Range: [40-400]. Helps when fullscreen TUI programs show garbled layout.') + parser.add_argument('--jit', '-j', required=False, dest='jit', action='store_true', + help='Trigger just-in-time (JIT) access at connect time. The gateway creates an ' + 'ephemeral account and/or elevates an existing account for the session and ' + 'reverts on disconnect. Requires JIT enabled on the record (via Web Vault ' + 'UI or `pam env apply`). Mutually exclusive with --credential, --host ' + 'and --host-record.') def get_parser(self): return PAMLaunchCommand.parser @@ -583,11 +598,12 @@ def execute(self, params: KeeperParams, **kwargs): root_logger.setLevel(logging.ERROR) try: - # TODO: Add JIT - note that allowSupplyHost overrides all other supply modes. - # When a PAM record has allowSupplyHost, allowSupplyUser, and JIT settings all enabled, - # the Web Vault (and this CLI) treat allowSupplyHost as the active mode and ignore the - # other two. Any validation logic below must reflect this precedence: if allowSupplyHost - # is True, treat the record as "host+credential supply" mode regardless of the other flags. + # Precedence note (matches Web Vault): allowSupplyHost > JIT > allowSupplyUser > linked. + # When a record has allowSupplyHost + jit_settings + allowSupplyUser all enabled, + # allowSupplyHost wins and the JIT block is ignored (the operator is supplying + # host+credential explicitly). launch.py enforces this below by rejecting --jit when + # the record already has allowSupplyHost, and by ignoring jit_settings downstream in + # terminal_connection.py when allowSupplyHost is on. record_token = kwargs.get('record') @@ -773,6 +789,47 @@ def _refresh_fetch(_params=params, _record_uid=record_uid, _self=self): # Get record host/port for fallback validation hostname_on_record, port_on_record = _get_host_port_from_record(record) + # --- Resolve --jit option (JIT just-in-time access) --- + # Validation rules: + # 1. --jit requires the record to have a meaningful jit_settings block + # (create_ephemeral and/or elevate set to true). + # 2. --jit is mutually exclusive with --credential / --host / --host-record + # since JIT provisions the credential itself. If operators genuinely need to + # override either of those, they should not use JIT for that session. + # 3. allowSupplyHost wins over JIT per Web Vault precedence; --jit on a record + # with allowSupplyHost is rejected with a clear error. + # 4. Pre-flight credential checks are bypassed for --jit ephemeral/both because the + # Gateway provisions the account. --jit elevation still requires a linked + # credential (enforced below in the "No --credential" branch). + jit_flag = bool(kwargs.get('jit')) + jit_settings = load_jit_settings(params=params, record=record, record_uid=record_uid) + jit_mode = derive_jit_mode(jit_settings) + + if jit_flag: + if not jit_settings or not jit_mode: + raise CommandError('pam launch', + '--jit requires the PAM record to have JIT enabled (createEphemeral and/or ' + 'elevate set in the Web Vault UI, or jit_settings declared in a ' + '`pam env apply` manifest). Neither the DAG nor pamSettings.options ' + 'carries an active JIT configuration for this record.') + if allow_supply_host: + raise CommandError('pam launch', + '--jit cannot be combined with allowSupplyHost on the record. ' + 'Either disable allowSupplyHost or launch without --jit to supply ' + 'host+credential manually.') + if kwargs.get('launch_credential') or kwargs.get('custom_host') or kwargs.get('host_record'): + raise CommandError('pam launch', + '--jit is mutually exclusive with --credential/--host/--host-record. ' + 'JIT provisions the credential itself; remove those flags to use --jit.') + # Note: for ephemeral_account_type=='domain', the pamDirectory binding is a + # separate DAG LINK edge (path="domain") on the resource vertex, not a field in + # jit_settings — see pam_import/extend.py:link_machine_to_directory. The Gateway + # owns that validation; Commander does not re-check it here. + # Propagate to downstream so the offer builder emits the right inputs. + kwargs['jit_mode'] = jit_mode + kwargs['jit_settings'] = jit_settings + logging.debug(f"JIT mode enabled: {jit_mode} (settings={jit_settings})") + # --- Resolve --credential option --- launch_credential = kwargs.get('launch_credential') launch_credential_uid = None @@ -928,18 +985,30 @@ def _refresh_fetch(_params=params, _record_uid=record_uid, _self=self): raise CommandError('pam launch', f'No hostname configured for record {record_uid}.') - # No CLI options at all -> validate DAG-linked credential has login + password or SSH key - if dag_linked_uid: - dag_cred_record = vault.KeeperRecord.load(params, dag_linked_uid) - if dag_cred_record and not _record_has_credentials(dag_cred_record, params): + # Credential validation. When --jit is active in ephemeral/both mode the + # Gateway provisions the account, so pre-existing credentials on the record + # are not required (and any linked credential is ignored downstream in favour + # of the ephemeral one). --jit elevation still requires a linked credential + # because the gateway elevates that account for the session. Without --jit + # the usual checks apply regardless of whether the record has jit_settings. + if not provisions_credential(jit_flag, jit_mode): + if dag_linked_uid: + dag_cred_record = vault.KeeperRecord.load(params, dag_linked_uid) + if dag_cred_record and not _record_has_credentials(dag_cred_record, params): + raise CommandError('pam launch', + f'Linked credential record {dag_linked_uid} has no usable auth ' + '(need login and password, or login and SSH private key). ' + 'Configure valid credentials or use --credential to override.') + elif not allow_supply_user and not allow_supply_host: + if jit_flag and jit_mode == JIT_MODE_ELEVATION: + raise CommandError('pam launch', + f'--jit elevation on record {record_uid} requires a linked ' + 'credential: elevation is applied on top of that account. ' + 'Link a pamUser record, enable allowSupplyUser, or switch ' + 'jit_settings to create_ephemeral=true.') raise CommandError('pam launch', - f'Linked credential record {dag_linked_uid} has no usable auth ' - '(need login and password, or login and SSH private key). ' - 'Configure valid credentials or use --credential to override.') - elif not allow_supply_user and not allow_supply_host: - raise CommandError('pam launch', - f'No credentials configured for record {record_uid}. ' - 'Configure a linked credential or enable allowSupplyUser/allowSupplyHost.') + f'No credentials configured for record {record_uid}. ' + 'Configure a linked credential or enable allowSupplyUser/allowSupplyHost.') # Gateway resolution — cache hit reuses the cached entry, cache # miss calls find_gateway and populates the cache on success. diff --git a/keepercommander/commands/pam_launch/terminal_connection.py b/keepercommander/commands/pam_launch/terminal_connection.py index 004264d6d..3f3650d01 100644 --- a/keepercommander/commands/pam_launch/terminal_connection.py +++ b/keepercommander/commands/pam_launch/terminal_connection.py @@ -70,6 +70,7 @@ from ...proto import pam_pb2 from ...display import bcolors from .python_handler import create_python_handler +from . import jit if TYPE_CHECKING: from ...params import KeeperParams @@ -422,6 +423,12 @@ def extract_terminal_settings( 'allowSupplyUser': False, 'allowSupplyHost': False, 'userRecordUid': None, + # JIT (just-in-time) access block. Loaded by ``jit.load_jit_settings`` from + # either the DAG (Web Vault authoritative) or the pamSettings.options mirror. + # jit_mode is derived by ``jit.derive_jit_mode`` and is one of the + # ``jit.JIT_MODE_*`` constants or ``None``. + 'jit_settings': None, + 'jit_mode': None, } # Extract hostname and port from record - enforce single non-empty host/pamHostname field. @@ -543,6 +550,16 @@ def extract_terminal_settings( # allowSupplyHost is at top level of pamSettings value, not inside connection settings['allowSupplyHost'] = pam_settings_value.get('allowSupplyHost', False) + # JIT settings come from either the DAG (Web Vault authoritative, camelCase) + # or pamSettings.options.jit_settings (declarative mirror, snake_case). + # jit.load_jit_settings prefers the DAG and returns snake_case either way; + # jit.derive_jit_mode centralises the mode rule. + jit_raw = jit.load_jit_settings(params=params, record=record, record_uid=record_uid) + jit_mode = jit.derive_jit_mode(jit_raw) + if jit_mode: + settings['jit_settings'] = dict(jit_raw) + settings['jit_mode'] = jit_mode + # Final port fallback to protocol default if settings['port'] is None: settings['port'] = DEFAULT_PORTS.get(protocol, 22) @@ -675,6 +692,15 @@ def create_connection_context(params: KeeperParams, # Required by the offer-building path to distinguish "flag enabled but nothing supplied" # from "flag enabled and user actually provided credentials". 'cliUserOverride': settings.get('cliUserOverride', False), + # JIT (just-in-time) settings. jit_mode is one of ``jit.JIT_MODE_*`` or ``None``. + # jit_settings is the normalised snake_case dict loaded by ``jit.load_jit_settings`` + # from the DAG (Web Vault) or pamSettings.options.jit_settings (declarative mirror), + # projected for the gateway by ``jit.build_{ephemeral,elevation}_payload``. + # jit_enabled is True only when the operator passed --jit on the CLI, giving explicit + # opt-in (records that happen to have jit_settings never auto-trigger JIT). + 'jit_settings': settings.get('jit_settings'), + 'jit_mode': settings.get('jit_mode'), + 'jit_enabled': settings.get('jit_enabled', False), } # Add protocol-specific settings @@ -691,6 +717,8 @@ def create_connection_context(params: KeeperParams, return context + + def _get_launch_credential_uid( params: 'KeeperParams', record_uid: str, @@ -1068,7 +1096,11 @@ def _build_guacamole_connection_settings( # Determine how to get credentials based on credential_type # Note: Even for 'userSupplied', if we have user_record_uid (from CLI --credential), extract credentials # because guacd_params go directly to guacd via our connect instruction - if credential_type == 'userSupplied' and not user_record_uid: + if credential_type == 'ephemeral': + # JIT ephemeral: gateway provisions the account and injects credentials into guacd + # server-side. Leave Commander's guacd_params creds empty so the gateway's values win. + logging.debug("Using ephemeral credential type - gateway supplies credentials") + elif credential_type == 'userSupplied' and not user_record_uid: # True user-supplied: no credentials provided at all # Note: user may not be able to provide via guacamole prompt since STDIN/STDOUT not open yet logging.debug("Using userSupplied credential type with no pamUser - leaving credentials empty") @@ -1442,9 +1474,17 @@ def _open_terminal_webrtc_tunnel(params: KeeperParams, user_record_uid = context.get('userRecordUid') # credential_type is None when using pamMachine credentials directly (backward compatible) - # Priority: if user_record_uid is provided (from CLI or record), use 'linked' to send those credentials + # Priority: JIT ephemeral wins (gateway supplies creds); otherwise if user_record_uid + # is provided (from CLI or record), use 'linked'; fallback to 'userSupplied' when + # allowSupply* flags are enabled. + jit_mode = context.get('jit_mode') if context.get('jit_enabled') else None credential_type = None - if user_record_uid: + if jit_mode in (jit.JIT_MODE_EPHEMERAL, jit.JIT_MODE_BOTH) and not allow_supply_host: + # Gateway provisions the ephemeral account and injects credentials server-side; + # leave guacd username/password empty on the client. + credential_type = 'ephemeral' + logging.debug("JIT ephemeral mode - using 'ephemeral' credential type (creds come from gateway)") + elif user_record_uid: # Linked user present (from CLI --credential or record) - use linked credentials credential_type = 'linked' logging.debug(f"Using 'linked' credential type with userRecordUid: {user_record_uid}") @@ -1721,12 +1761,36 @@ def _open_terminal_webrtc_tunnel(params: KeeperParams, # Gateway credential types: # - 'linked': Look up credential in DAG (for records with DAG-linked pamUser) # - 'userSupplied': Skip DAG lookup, credentials from ConnectAs (-cr) or user prompt + # - 'ephemeral': Gateway provisions a short-lived account (JIT create_ephemeral); no + # credentials flow from Commander. Optional jitElevation adds a group/role delta + # applied on top of the ephemeral or linked account. # - None: Use pamMachine credentials directly - # Priority: prefer 'linked' when DAG has credentials (even if allowSupply* is enabled). - # Use 'userSupplied' only when no linked credential but allowSupply* enabled. + # Priority: allowSupplyHost > JIT (ephemeral) > cliUserOverride > userRecordUid > none. + # allowSupplyHost wins per the Web Vault contract (also noted in the launch.py TODO now + # removed); JIT elevation piggy-backs on 'linked' so the gateway still receives the + # linked credential to elevate. credential_type_for_gateway = None cli_user_override = context.get('cliUserOverride', False) - if cli_user_override: + jit_enabled = context.get('jit_enabled', False) + jit_mode = context.get('jit_mode') if jit_enabled else None + jit_settings = context.get('jit_settings') or {} if jit_enabled else {} + # allowSupplyHost disables JIT per Web Vault precedence; launch.py already rejects the + # combination when JIT is requested, so here we simply let the existing userSupplied / + # linked path handle allowSupplyHost records. + if allow_supply_host and jit_mode: + logging.debug( + "allowSupplyHost is enabled; ignoring jit_mode=%s per Web Vault precedence", + jit_mode, + ) + jit_mode = None + + if jit_mode in (jit.JIT_MODE_EPHEMERAL, jit.JIT_MODE_BOTH): + # Gateway creates a short-lived account; Commander does not carry the credential. + # For 'both' (create_ephemeral + elevate) the gateway also applies the elevation + # delta, so jitElevation is still emitted alongside jitSettings. + credential_type_for_gateway = 'ephemeral' + logging.debug("JIT ephemeral mode active - using 'ephemeral' for gateway") + elif cli_user_override: # User explicitly supplied a different credential via -cr. # The -cr record is NOT DAG-linked to this machine so 'linked' would fail; # credentials arrive via the ConnectAs payload (built in launch.py after tunnel opens). @@ -1735,9 +1799,16 @@ def _open_terminal_webrtc_tunnel(params: KeeperParams, credential_type_for_gateway = 'userSupplied' logging.debug("CLI credential override active - using 'userSupplied' for gateway") elif user_record_uid: - # DAG-linked pamUser (no CLI override) - gateway looks up credentials via DAG + # DAG-linked pamUser (no CLI override) - gateway looks up credentials via DAG. + # When jit_mode == 'elevation' this path still applies: the gateway elevates the + # linked account for the session and reverts on disconnect. credential_type_for_gateway = 'linked' - logging.debug(f"Using 'linked' credential type for gateway with userRecordUid: {user_record_uid}") + if jit_mode == jit.JIT_MODE_ELEVATION: + logging.debug( + f"JIT elevation mode active - using 'linked' credentials + jitElevation for {user_record_uid}" + ) + else: + logging.debug(f"Using 'linked' credential type for gateway with userRecordUid: {user_record_uid}") else: logging.debug(f"No linked pamUser for record {record_uid} - using pamMachine credentials directly") @@ -1766,12 +1837,34 @@ def _open_terminal_webrtc_tunnel(params: KeeperParams, if credential_type_for_gateway == 'linked' and user_record_uid: inputs['credentialType'] = 'linked' inputs['userRecordUid'] = user_record_uid + # JIT elevation: linked account stays, gateway applies group/role delta for + # the session and reverts on disconnect. + if jit_mode == jit.JIT_MODE_ELEVATION: + elevation_payload = jit.build_elevation_payload(jit_settings) + if elevation_payload: + inputs['jitElevation'] = elevation_payload + logging.debug(f"Attached jitElevation payload: {elevation_payload}") elif credential_type_for_gateway == 'userSupplied': inputs['credentialType'] = 'userSupplied' # For userSupplied, set allow_supply_user flag in connect_as_settings # This matches gateway behavior (line 1203 in tunnel_vault_record.py) inputs['allowSupplyUser'] = True logging.debug("Using userSupplied credential type - user will provide credentials") + elif credential_type_for_gateway == 'ephemeral': + # JIT ephemeral: gateway provisions a short-lived account and returns creds. + # For 'both' (create_ephemeral + elevate), jitElevation is emitted alongside. + inputs['credentialType'] = 'ephemeral' + ephemeral_payload = jit.build_ephemeral_payload(jit_settings) + if ephemeral_payload: + inputs['jitSettings'] = ephemeral_payload + if jit_mode == jit.JIT_MODE_BOTH: + elevation_payload = jit.build_elevation_payload(jit_settings) + if elevation_payload: + inputs['jitElevation'] = elevation_payload + logging.debug( + f"Using ephemeral credential type - gateway will provision JIT account " + f"(jitSettings={ephemeral_payload})" + ) # else: no credentialType - gateway uses pamMachine credentials directly (backward compatible) # Add 2FA value if workflow requires MFA @@ -2114,6 +2207,14 @@ def launch_terminal_connection(params: KeeperParams, custom_port=kwargs.get('custom_port'), dag_linked_uid=kwargs.get('dag_linked_uid', _DAG_UID_UNSET), ) + # JIT is opt-in via --jit. extract_terminal_settings always reads the record's + # jit_settings so validation can inspect them, but we only treat JIT as "active" + # (i.e. emit it to the gateway) when the operator explicitly requested it. + settings['jit_enabled'] = bool(kwargs.get('jit')) + if not settings['jit_enabled']: + # Clear derived jit_mode so downstream branches fall through to the normal path. + settings['jit_mode'] = None + settings['jit_settings'] = None logging.debug(f"Extracted settings: hostname={settings['hostname']}, port={settings['port']}") _launch_tc.checkpoint('settings_extracted') diff --git a/unit-tests/pam/test_pam_launch_jit.py b/unit-tests/pam/test_pam_launch_jit.py new file mode 100644 index 000000000..a830027b8 --- /dev/null +++ b/unit-tests/pam/test_pam_launch_jit.py @@ -0,0 +1,276 @@ +"""Unit tests for just-in-time (JIT) support in `pam launch`. + +All the shape-handling lives in ``keepercommander.commands.pam_launch.jit``; +these tests pin its public surface. The live end-to-end path is exercised by +``scripts/_acme_lab_pr_validation.py`` in the test-env repo. +""" + +import sys +import unittest +from types import SimpleNamespace + +# launch.py / terminal_connection.py pull in large transitive dep trees +# (WebRTC / router / Guacamole) that require Python >= 3.8. Matches the +# version-gated import pattern in test_pam_tunnel.py. +if sys.version_info >= (3, 8): + from keepercommander.commands.pam_launch import jit + from keepercommander.commands.pam_launch.jit import ( + JIT_MODE_BOTH, + JIT_MODE_ELEVATION, + JIT_MODE_EPHEMERAL, + build_elevation_payload, + build_ephemeral_payload, + derive_jit_mode, + load_jit_settings, + normalize_jit_settings, + provisions_credential, + ) + + def _record_with_pam_settings(pam_settings_value): + """Build a record-like object exposing get_typed_field('pamSettings').""" + field = SimpleNamespace(get_default_value=lambda _t=dict: pam_settings_value) + return SimpleNamespace( + get_typed_field=lambda name: field if name == 'pamSettings' else None, + record_uid='r1', + ) + + + class TestModeConstants(unittest.TestCase): + def test_constants_are_distinct_non_empty_strings(self): + values = [JIT_MODE_EPHEMERAL, JIT_MODE_ELEVATION, JIT_MODE_BOTH] + self.assertEqual(len(set(values)), 3) + for v in values: + self.assertIsInstance(v, str) + self.assertTrue(v) + + + class TestNormalizeJitSettings(unittest.TestCase): + def test_returns_none_for_non_dict(self): + self.assertIsNone(normalize_jit_settings(None)) + self.assertIsNone(normalize_jit_settings('not-a-dict')) + self.assertIsNone(normalize_jit_settings([])) + + def test_empty_dict_returns_none(self): + self.assertIsNone(normalize_jit_settings({})) + + def test_camelcase_from_web_vault_dag(self): + """Exact keys written by DagJitSettingsObject.to_dag_dict.""" + out = normalize_jit_settings({ + 'createEphemeral': True, + 'elevate': True, + 'elevationMethod': 'group', + 'elevationString': 'wheel,sudo', + 'baseDistinguishedName': 'OU=JIT,DC=acme,DC=corp', + 'ephemeralAccountType': 'linux', + }) + self.assertEqual(out, { + 'create_ephemeral': True, + 'elevate': True, + 'elevation_method': 'group', + 'elevation_string': 'wheel,sudo', + 'base_distinguished_name': 'OU=JIT,DC=acme,DC=corp', + 'ephemeral_account_type': 'linux', + }) + + def test_snake_case_passthrough(self): + raw = {'create_ephemeral': True, 'ephemeral_account_type': 'linux'} + self.assertEqual(normalize_jit_settings(raw), raw) + + def test_snake_case_wins_on_collision(self): + """Pathological case: caller supplies both castings. Snake wins.""" + self.assertEqual( + normalize_jit_settings({'createEphemeral': False, 'create_ephemeral': True}), + {'create_ephemeral': True}, + ) + + def test_unknown_keys_preserved(self): + """New DAG fields must not be dropped by the normaliser.""" + out = normalize_jit_settings({'createEphemeral': True, 'futureField': 'x'}) + self.assertEqual(out, {'create_ephemeral': True, 'futureField': 'x'}) + + + class TestDeriveJitMode(unittest.TestCase): + def test_none_for_non_dict(self): + self.assertIsNone(derive_jit_mode(None)) + self.assertIsNone(derive_jit_mode('nope')) + + def test_none_when_no_flags(self): + self.assertIsNone(derive_jit_mode({})) + self.assertIsNone(derive_jit_mode({'elevation_method': 'group'})) + self.assertIsNone(derive_jit_mode({'create_ephemeral': False, 'elevate': False})) + + def test_ephemeral_only(self): + self.assertEqual(derive_jit_mode({'create_ephemeral': True}), JIT_MODE_EPHEMERAL) + + def test_elevation_only(self): + self.assertEqual(derive_jit_mode({'elevate': True}), JIT_MODE_ELEVATION) + + def test_both(self): + self.assertEqual( + derive_jit_mode({'create_ephemeral': True, 'elevate': True}), + JIT_MODE_BOTH, + ) + + def test_works_after_camelcase_normalisation(self): + mode = derive_jit_mode(normalize_jit_settings({ + 'createEphemeral': True, 'ephemeralAccountType': 'linux', + })) + self.assertEqual(mode, JIT_MODE_EPHEMERAL) + + + class TestBuildEphemeralPayload(unittest.TestCase): + def test_returns_empty_for_non_dict(self): + self.assertEqual(build_ephemeral_payload(None), {}) + self.assertEqual(build_ephemeral_payload(42), {}) + + def test_keeps_only_ephemeral_keys(self): + payload = build_ephemeral_payload({ + 'create_ephemeral': True, + 'ephemeral_account_type': 'linux', + 'base_distinguished_name': 'OU=JIT,DC=acme,DC=corp', + 'pam_directory_uid_ref': 'ref-uid', + # Elevation keys must not leak into the ephemeral payload + 'elevate': True, + 'elevation_method': 'group', + 'elevation_string': 'wheel', + }) + self.assertEqual(set(payload), { + 'create_ephemeral', 'ephemeral_account_type', + 'base_distinguished_name', 'pam_directory_uid_ref', + }) + self.assertEqual(payload['ephemeral_account_type'], 'linux') + + def test_drops_empty_and_none_values(self): + payload = build_ephemeral_payload({ + 'create_ephemeral': True, + 'ephemeral_account_type': 'linux', + 'base_distinguished_name': '', + 'pam_directory_uid_ref': None, + }) + self.assertEqual(set(payload), {'create_ephemeral', 'ephemeral_account_type'}) + + + class TestBuildElevationPayload(unittest.TestCase): + def test_returns_empty_for_non_dict(self): + self.assertEqual(build_elevation_payload(None), {}) + + def test_keeps_only_elevation_keys(self): + payload = build_elevation_payload({ + 'elevate': True, + 'elevation_method': 'group', + 'elevation_string': 'wheel,sudo', + # Ephemeral keys must not leak + 'create_ephemeral': True, + 'ephemeral_account_type': 'linux', + }) + self.assertEqual(set(payload), {'elevate', 'elevation_method', 'elevation_string'}) + + def test_drops_empty_strings(self): + payload = build_elevation_payload({ + 'elevate': True, 'elevation_method': 'role', 'elevation_string': '', + }) + self.assertEqual(set(payload), {'elevate', 'elevation_method'}) + + + class TestPayloadDisjointness(unittest.TestCase): + """For JIT_MODE_BOTH the gateway receives both payloads on the same + inputs dict; they must not collide on any key.""" + + def test_no_overlap(self): + combined = { + 'create_ephemeral': True, + 'ephemeral_account_type': 'linux', + 'base_distinguished_name': 'OU=JIT,DC=acme,DC=corp', + 'pam_directory_uid_ref': 'ref', + 'elevate': True, + 'elevation_method': 'group', + 'elevation_string': 'wheel,sudo', + } + eph = build_ephemeral_payload(combined) + elev = build_elevation_payload(combined) + self.assertFalse(set(eph) & set(elev)) + + + class TestLoadJitSettings(unittest.TestCase): + """DAG wins over typed-field; typed-field fallback when DAG empty.""" + + def setUp(self): + self._orig = jit._dag_jit_settings + + def tearDown(self): + jit._dag_jit_settings = self._orig + + def _stub_dag(self, result): + jit._dag_jit_settings = lambda p, uid: result + + def test_none_when_nothing(self): + self._stub_dag(None) + self.assertIsNone(load_jit_settings(params=SimpleNamespace(), record_uid='r1')) + + def test_none_when_no_inputs(self): + """No record and no uid => can't look anything up.""" + self.assertIsNone(load_jit_settings(params=SimpleNamespace())) + + def test_typed_field_happy_path(self): + self._stub_dag(None) + expected = {'create_ephemeral': True, 'ephemeral_account_type': 'linux'} + record = _record_with_pam_settings({'options': {'jit_settings': expected}}) + self.assertEqual(load_jit_settings(params=SimpleNamespace(), record=record), expected) + + def test_dag_wins_when_both_present(self): + dag_result = {'create_ephemeral': True, 'ephemeral_account_type': 'domain'} + self._stub_dag(dag_result) + record = _record_with_pam_settings({'options': {'jit_settings': { + 'create_ephemeral': True, 'ephemeral_account_type': 'linux', + }}}) + self.assertEqual(load_jit_settings(params=SimpleNamespace(), record=record), dag_result) + + def test_non_dict_typed_field_ignored(self): + self._stub_dag(None) + record = _record_with_pam_settings({'options': {'jit_settings': 'bogus'}}) + self.assertIsNone(load_jit_settings(params=SimpleNamespace(), record=record)) + + def test_record_uid_derived_from_record(self): + """record.record_uid is used when record_uid kwarg omitted.""" + seen = {} + jit._dag_jit_settings = lambda p, uid: seen.setdefault('uid', uid) or None + record = _record_with_pam_settings({'options': {}}) + load_jit_settings(params=SimpleNamespace(), record=record) + self.assertEqual(seen['uid'], 'r1') + + + class TestProvisionsCredential(unittest.TestCase): + """--jit ephemeral/both let the gateway provision the credential and + therefore bypass the pre-flight credential check in pam_launch.launch. + Everything else (jit_flag=False, elevation-only, jit_mode=None) must + still take the regular path.""" + + def test_ephemeral_with_flag(self): + self.assertTrue(provisions_credential(True, JIT_MODE_EPHEMERAL)) + + def test_both_with_flag(self): + self.assertTrue(provisions_credential(True, JIT_MODE_BOTH)) + + def test_elevation_with_flag_still_needs_cred(self): + self.assertFalse(provisions_credential(True, JIT_MODE_ELEVATION)) + + def test_none_mode_with_flag(self): + self.assertFalse(provisions_credential(True, None)) + + def test_flag_off_regardless_of_mode(self): + """A record with JIT configured but launched without --jit must + still go through the normal credential checks.""" + for mode in (JIT_MODE_EPHEMERAL, JIT_MODE_BOTH, JIT_MODE_ELEVATION, None): + self.assertFalse( + provisions_credential(False, mode), + f'--jit flag off should never bypass cred check (mode={mode})', + ) + + def test_falsey_flag_coerced(self): + """Defensive: 0 / '' / None are all falsey and must not bypass.""" + for flag in (0, '', None): + self.assertFalse(provisions_credential(flag, JIT_MODE_EPHEMERAL)) + + +if __name__ == '__main__': + unittest.main()