Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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