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