Skip to content
Open
5 changes: 5 additions & 0 deletions examples/sdk_examples/secrets_manager/app_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@ def add_client_to_ksm_app(
unlock_ip: bool = False,
first_access_expires_in_minutes: int = DEFAULT_FIRST_ACCESS_EXPIRES_MINUTES,
access_expire_in_minutes: Optional[int] = None,
client_type: int = ksm_management.GENERAL,
) -> None:
"""
Add a client device to a KSM app using
Expand All @@ -542,6 +543,7 @@ def add_client_to_ksm_app(
unlock_ip: If True, do not lock the client to the current IP.
first_access_expires_in_minutes: Minutes until the one-time token expires (default 60).
access_expire_in_minutes: Optional minutes until app access expires (None = never).
client_type: Type of client to add (default GENERAL).
"""
vault, app_uid = _vault_and_app_uid(keeper_auth_context, app_uid_or_name)
try:
Expand Down Expand Up @@ -571,6 +573,7 @@ def add_client_to_ksm_app(
access_expire_in_ms=access_expire_in_ms,
master_key=master_key,
server=server,
client_type=client_type,
)
print(result["output_string"])
if result.get("token_info"):
Expand Down Expand Up @@ -641,6 +644,7 @@ def main() -> None:
unlock_ip = False # Set True to allow config from any IP
first_access_expires_in_minutes = 60 # Token validity (max 1440 = 24h)
access_expire_in_minutes = None # None = never expire app access
client_type = ksm_management.GENERAL
add_client_to_ksm_app(
keeper_auth_context,
app_uid_or_name,
Expand All @@ -649,6 +653,7 @@ def main() -> None:
unlock_ip=unlock_ip,
first_access_expires_in_minutes=first_access_expires_in_minutes,
access_expire_in_minutes=access_expire_in_minutes,
client_type=client_type,
)
elif action == "remove":
client_names_or_ids = ["<client_id_1>", "<client_id_2>"] # Client ID(s)
Expand Down
8 changes: 6 additions & 2 deletions keepercli-package/setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,16 @@ classifiers =
Operating System :: OS Independent
Natural Language :: English
Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Programming Language :: Python :: 3.12
Programming Language :: Python :: 3.13
Programming Language :: Python :: 3.14
Topic :: Security
keywords = security, password

[options]
python_requires = >=3.8
python_requires = >=3.10
package_dir =
= src
include_package_data = True
Expand Down
334 changes: 268 additions & 66 deletions keepercli-package/src/keepercli/commands/pam/pam_config.py

Large diffs are not rendered by default.

103 changes: 69 additions & 34 deletions keepercli-package/src/keepercli/commands/pam/pam_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@

from keepersdk import utils
from keepersdk.helpers.keeper_dag import dag_utils
from keepersdk.helpers.keeper_dag.constants import (
PAM_CONFIGURATIONS,
PAM_DATABASE,
PAM_DIRECTORY,
PAM_MACHINE,
PAM_RESOURCES,
PAM_USER,
)
from keepersdk.helpers.tunnel.tunnel_graph import TunnelDAG
from keepersdk.helpers.tunnel.tunnel_utils import get_keeper_tokens, get_config_uid
from keepersdk.vault import record_management, vault_record
from keepersdk.vault import vault_record

from .. import base
from ... import api
from ...params import KeeperParams
from . import pam_utils


logger = api.get_logger()
Expand All @@ -18,6 +27,15 @@
protocols = ['', 'http', 'kubernetes', 'mysql', 'postgresql', 'rdp', 'sql-server', 'ssh', 'telnet', 'vnc']
choices = ['on', 'off', 'default']

# Resource + RBI + PAM configs (reuse keeper_dag constants where they exist).
_PAM_CONNECTION_RECORD_TYPES = (*PAM_RESOURCES, 'pamRemoteBrowser', *PAM_CONFIGURATIONS)
_PAM_SEED_RECORD_TYPES = (PAM_DATABASE, PAM_DIRECTORY, PAM_MACHINE, 'pamRemoteBrowser')
_PAM_RESOURCE_USER_LINK_TYPES = (PAM_DATABASE, PAM_DIRECTORY, PAM_MACHINE)


def _is_pam_config_record(record: vault_record.TypedRecord) -> bool:
return record.record_type in PAM_CONFIGURATIONS


class PAMConnectionEditCommand(base.ArgparseCommand):

Expand Down Expand Up @@ -75,29 +93,36 @@ def execute(self, context: KeeperParams, **kwargs):
record_name = kwargs.get('record')
if not record_name:
raise base.CommandError(f'Record parameter is required.')
record = vault.vault_data.load_record(record_name)
record = pam_utils.load_typed_record(context, record_name)
if not record:
raise base.CommandError(f'Record \"{record_name}\" not found.')
if not isinstance(record, vault_record.TypedRecord):
raise base.CommandError(f'Record \"{record_name}\" can not be edited.')

config_name = kwargs.get('config', None)
cfg_rec = vault.vault_data.load_record(config_name)
if not cfg_rec and record.version == 6:
cfg_rec = record
config_uid = cfg_rec.record_uid if cfg_rec else None

record_uid = record.record_uid
record_type = record.record_type
if record_type not in ("pamMachine pamDatabase pamDirectory pamNetworkConfiguration pamAwsConfiguration "
"pamRemoteBrowser pamAzureConfiguration").split():
if record_type not in _PAM_CONNECTION_RECORD_TYPES:
raise base.CommandError(f"This record's type is not supported for connections. "
f"Connections are only supported on pamMachine, pamDatabase, pamDirectory, "
f"pamRemoteBrowser, pamNetworkConfiguration pamAwsConfiguration, and "
f"pamAzureConfiguration records")

encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(vault)
if record_type in "pamNetworkConfiguration pamAwsConfiguration pamAzureConfiguration".split():

config_name = kwargs.get('config', None)
cfg_rec = pam_utils.load_typed_record(context, config_name) if config_name else None
if not cfg_rec and _is_pam_config_record(record):
cfg_rec = record

# For resource records, fall back to the PAM config already linked in the DAG.
existing_config_uid = None
if not _is_pam_config_record(record):
existing_config_uid = get_config_uid(
vault, encrypted_session_token, encrypted_transmission_key, record_uid)
existing_config_uid = str(existing_config_uid) if existing_config_uid else ''
if not cfg_rec and existing_config_uid:
cfg_rec = pam_utils.load_typed_record(context, existing_config_uid)
config_uid = cfg_rec.record_uid if cfg_rec else None

if record_type in PAM_CONFIGURATIONS:
tdag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, record_uid,
is_config=True, transmission_key=transmission_key)
tdag.edit_tunneling_config(connections=_connections, session_recording=_recording, typescript_recording=_typescript_recording)
Expand All @@ -111,7 +136,7 @@ def execute(self, context: KeeperParams, **kwargs):
base64_seed = utils.base64_url_encode(seed)
record_seed = vault_record.TypedField.create_field('trafficEncryptionSeed', base64_seed, required=False)

record_types_with_seed = ("pamDatabase", "pamDirectory", "pamMachine", "pamRemoteBrowser")
record_types_with_seed = _PAM_SEED_RECORD_TYPES
if traffic_encryption_key:
traffic_encryption_key.value = [base64_seed]
elif record.record_type in record_types_with_seed:
Expand Down Expand Up @@ -183,32 +208,41 @@ def execute(self, context: KeeperParams, **kwargs):
logger.debug(f'Unexpected value for --key-events {key_events} (ignored)')

if dirty:
record_management.update_record(vault, record)
vault.sync_down()
pam_utils.save_typed_record(vault, record)

traffic_encryption_key = record.get_typed_field('trafficEncryptionSeed')
if not traffic_encryption_key:
raise base.CommandError(f"Unable to add Seed to record {record_uid}. "
f"Please make sure you have edit rights to record {record_uid}")
dirty = False

existing_config_uid = get_config_uid(vault, encrypted_session_token, encrypted_transmission_key, record_uid)
if not config_uid:
raise base.CommandError(
"No PAM Configuration UID set. "
"This must be set or supplied for connections to work. "
"Pass --config [ConfigUID] (see `pam config list`)."
)

tdag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, config_uid,
transmission_key=transmission_key)
old_dag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, existing_config_uid,
transmission_key=transmission_key)
is_config=True, transmission_key=transmission_key)

if config_uid and existing_config_uid != config_uid:
if existing_config_uid and existing_config_uid != config_uid:
old_dag = TunnelDAG(
vault, encrypted_session_token, encrypted_transmission_key, existing_config_uid,
is_config=True, transmission_key=transmission_key,
)
old_dag.remove_from_dag(record_uid)
tdag.link_resource_to_config(record_uid)
elif not tdag.is_tunneling_config_set_up(record_uid):
tdag.link_resource_to_config(record_uid)

if tdag is None or not tdag.linking_dag.has_graph:
raise base.CommandError(f"No PAM Configuration UID set. "
f"This must be set or supplied for connections to work. This can be done by adding "
f"' --config [ConfigUID] "
f" The ConfigUID can be found by running "
f"'pam config list'")
if not tdag.linking_dag.has_graph:
raise base.CommandError(
f"No PAM Configuration DAG found for {config_uid}. "
"Initialize tunnel settings on the config first, e.g.\n"
f" pam connection edit {config_uid} --connections on "
"--connections-recording on"
)

if not tdag.check_tunneling_enabled_config(enable_connections=_connections,
enable_session_recording=_recording,
Expand Down Expand Up @@ -255,20 +289,21 @@ def execute(self, context: KeeperParams, **kwargs):
typescript_recording=kwargs.get('typescriptrecording', None))

admin_name = kwargs.get('admin')
adm_rec = vault.vault_data.load_record(admin_name)
admin_uid = adm_rec.record_uid if adm_rec else None
if admin_uid and record_type in ("pamDatabase", "pamDirectory", "pamMachine"):
tdag.link_user_to_resource(admin_uid, record_uid, is_admin=True, belongs_to=True)
if admin_name:
adm_rec = pam_utils.load_typed_record(context, admin_name)
admin_uid = adm_rec.record_uid if adm_rec else None
if admin_uid and record_type in _PAM_RESOURCE_USER_LINK_TYPES:
tdag.link_user_to_resource(admin_uid, record_uid, is_admin=True, belongs_to=True)

launch_user_name = kwargs.get('launch_user')
if launch_user_name:
launch_rec = vault.vault_data.load_record(launch_user_name)
launch_rec = pam_utils.load_typed_record(context, launch_user_name)
if not launch_rec:
raise base.CommandError(f'Launch user record "{launch_user_name}" not found.')
if not isinstance(launch_rec, vault_record.TypedRecord) or launch_rec.record_type != 'pamUser':
if launch_rec.record_type != PAM_USER:
raise base.CommandError(f'Launch user record must be a pamUser record type.')
launch_uid = launch_rec.record_uid
if record_type in ("pamDatabase", "pamDirectory", "pamMachine"):
if record_type in _PAM_RESOURCE_USER_LINK_TYPES:
tdag.clear_launch_credential_for_resource(record_uid, exclude_user_uid=launch_uid)
tdag.link_user_to_resource(launch_uid, record_uid, is_admin=True, belongs_to=True)
tdag.upgrade_resource_meta_to_v1(record_uid)
Expand Down
62 changes: 44 additions & 18 deletions keepercli-package/src/keepercli/commands/pam/pam_rbi.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@
from keepersdk.helpers.keeper_dag.constants import PAM_CONFIGURATIONS
from keepersdk.helpers.tunnel.tunnel_graph import TunnelDAG
from keepersdk.helpers.tunnel.tunnel_utils import get_keeper_tokens, get_config_uid
from keepersdk.vault import record_management, vault_online, vault_record
from keepersdk.vault import (
nsf_management,
record_management,
vault_extensions,
vault_online,
vault_record,
)

from .. import base
from ... import api
from ...helpers import record_utils
from ...params import KeeperParams
from . import pam_utils

choices = ['on', 'off', 'default']

logger = api.get_logger()
Expand Down Expand Up @@ -47,8 +55,24 @@ def _bootstrap_rbi_record(record: vault_record.TypedRecord) -> bool:


def _save_rbi_record(vault: vault_online.VaultOnline, record: vault_record.TypedRecord) -> None:
"""Persist RBI record body changes with a fresh revision (sync + retry on out-of-sync)."""
"""Persist RBI record body changes (classic or NSF) with sync + out-of-sync retry."""
vault.sync_down()
if pam_utils.is_nsf_record(vault, record.record_uid):
schema = vault.vault_data.get_record_type_by_name(record.record_type)
record_data = vault_extensions.extract_typed_record_data(record, schema)
try:
nsf_management.update_nsf_record(
vault,
record.record_uid,
title=record.title,
record_type=record.record_type,
record_data=record_data,
request_sync=True,
)
except nsf_management.NsfError as err:
raise base.CommandError(str(err)) from err
vault.sync_down()
return
try:
record_management.update_record(vault, record)
except KeeperApiError as err:
Expand All @@ -69,18 +93,23 @@ def _resolve_pam_config_record(
context: KeeperParams,
config_ref: str,
) -> Optional[vault_record.TypedRecord]:
"""Resolve a PAM configuration by UID or title (vault index version 6, not TypedRecord.version)."""
"""Resolve a PAM configuration by UID or title (classic v6 or NSF)."""
if not config_ref or context.vault is None:
return None
vault = context.vault
info = vault.vault_data.get_record(config_ref)
if not info:
info = record_utils.try_resolve_single_record(config_ref, context)
if not info or info.version != 6 or info.record_type not in PAM_CONFIGURATIONS:
return None
loaded = vault.vault_data.load_record(info.record_uid)
if isinstance(loaded, vault_record.TypedRecord):
return loaded
if info and info.version == 6 and info.record_type in PAM_CONFIGURATIONS:
loaded = vault.vault_data.load_record(info.record_uid)
if isinstance(loaded, vault_record.TypedRecord):
return loaded

nsf_uid = pam_utils.resolve_nsf_record_uid(vault, config_ref)
if nsf_uid:
typed = pam_utils.load_nsf_typed_record(vault, nsf_uid)
if typed and typed.record_type in PAM_CONFIGURATIONS:
return typed
return None


Expand Down Expand Up @@ -191,14 +220,9 @@ def execute(self, context: KeeperParams, **kwargs):

vault = context.vault

record_info = record_utils.try_resolve_single_record(record_name, context)
if not record_info:
raise base.CommandError(f'Record \"{record_name}\" not found.')
record = vault.vault_data.load_record(record_info.record_uid)
record = pam_utils.load_typed_record(context, record_name)
if not record:
raise base.CommandError(f'Record \"{record_name}\" not found.')
if not isinstance(record, vault_record.TypedRecord):
raise base.CommandError(f'Record \"{record_name}\" can not be edited.')

record_uid = record.record_uid
record_type = record.record_type
Expand All @@ -210,12 +234,14 @@ def execute(self, context: KeeperParams, **kwargs):
dirty = _bootstrap_rbi_record(record)

if autofill:
af_rec = vault.vault_data.load_record(autofill)
af_rec = pam_utils.load_typed_record(context, autofill)
if not af_rec:
raise base.CommandError(f'Record \"{autofill}\" not found.')
if not isinstance(af_rec, vault_record.TypedRecord) or af_rec.version != 3 or af_rec.record_type not in ("login", "pamUser"):
raise base.CommandError(f'Autofill credentials record \"{af_rec.record_uid}\" can not be linked. '
' RBI autofill credential records must be of type "login" or "pamUser"')
if af_rec.record_type not in ("login", "pamUser"):
raise base.CommandError(
f'Autofill credentials record \"{af_rec.record_uid}\" can not be linked. '
' RBI autofill credential records must be of type "login" or "pamUser"'
)

rbs_fld = record.get_typed_field('pamRemoteBrowserSettings')
val1 = rbs_fld.value[0] if isinstance(rbs_fld, vault_record.TypedField) and rbs_fld.value else {}
Expand Down
Loading