Skip to content
Open
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
341 changes: 286 additions & 55 deletions keepercli-package/src/keepercli/commands/pam/pam_config.py

Large diffs are not rendered by default.

214 changes: 160 additions & 54 deletions keepercli-package/src/keepercli/commands/pam/pam_rotation.py

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion keepercli-package/src/keepercli/commands/secrets_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,8 @@ def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None:
)
parser.add_argument(
'--secret', '-s', type=str, required=False,
help='Record UID(s) - space separated (e.g., "uid1 uid2 uid3")'
help='Record/folder UID(s) or NSF folder/record name — space separated '
'(classic records, classic shared folders, NSF folders, NSF records)'
)

def execute(self, context: KeeperParams, **kwargs) -> None:
Expand Down
1 change: 1 addition & 0 deletions keepercli-package/src/keepercli/helpers/ksm_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@ def print_shared_secrets_info(shared_secrets: List[ksm.SharedSecretsInfo]) -> No
rows = [
[secrets.type, secrets.uid, secrets.name, secrets.permissions]
for secrets in shared_secrets
if secrets is not None
]
report_utils.dump_report_data(rows, shares_table_fields, fmt='table')
77 changes: 69 additions & 8 deletions keepercli-package/src/keepercli/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ def set_auth(self, value: keeper_auth.KeeperAuth, *,
self._keeper_config.get_connection, enterprise_id)
self._enterprise_loader = enterprise_loader.EnterpriseLoader(self._auth, enterprise_storage, tree_key=tree_key)
self.enterprise_down()
# Persist rotations from the vault sync into PAM sqlite now that the plugin exists.
self.refresh_record_rotations()

@property
def enterprise_loader(self) -> enterprise_types.IEnterpriseLoader:
Expand Down Expand Up @@ -256,21 +258,80 @@ def vault_down(self):
self.refresh_record_rotations()

def refresh_record_rotations(self) -> None:
"""Reload vault ``recordRotations`` into :attr:`pam_plugin` after a vault sync (enterprise admins only)."""
if not self._auth or not self._auth.auth_context.is_enterprise_admin:
return
if self._enterprise_loader is None:
if not self._auth:
return
try:
self.pam_plugin.sync_record_rotations_from_vault()
if self._vault is not None:
self._merge_nsf_rotation_chunks_into_vault_cache()
if (not self._auth.auth_context.is_enterprise_admin
or self._enterprise_loader is None):
return
replace_all = False
rows = []
if self._vault is not None:
replace_all = self._vault.consume_rotations_cleared()
from keepersdk.plugins.pam import pam_storage as pam_stor
rows = [
pam_stor.PamRecordRotation(
record_uid=info.record_uid,
revision=info.revision,
configuration_uid=info.configuration_uid,
schedule=info.schedule,
pwd_complexity=info.pwd_complexity,
disabled=info.disabled,
resource_uid=info.resource_uid,
last_rotation=info.last_rotation,
last_rotation_status=info.last_rotation_status,
)
for info in self._vault.record_rotation_cache.values()
]
self.pam_plugin.merge_record_rotations(rows, replace_all=replace_all)
except Exception as e:
keepersdk_utils.get_logger().warning('refresh_record_rotations failed: %s', e)

def _merge_nsf_rotation_chunks_into_vault_cache(self) -> None:
"""Secondary source: NSF list_chunks for recordRotationData (already written by sync)."""
if not self._vault:
return
try:
nsf_storage = self._vault.nsf
except Exception:
return
if nsf_storage is None:
return
from keepersdk.vault import nsf_sync
from keepersdk.plugins.pam import pam_storage as pam_stor

for item in nsf_sync.load_list_chunks(nsf_storage, nsf_sync.CHUNK_RECORD_ROTATION):
if not isinstance(item, dict):
continue
row = pam_stor.pam_record_rotation_from_nsf_dict(item)
if not row or not row.record_uid:
continue
if row.record_uid in self._vault.record_rotation_cache:
continue
self._vault.record_rotation_cache[row.record_uid] = pam_types.PamRecordRotationInfo(
record_uid=row.record_uid,
revision=row.revision,
configuration_uid=row.configuration_uid,
schedule=row.schedule,
pwd_complexity=row.pwd_complexity,
disabled=row.disabled,
resource_uid=row.resource_uid,
last_rotation=row.last_rotation,
last_rotation_status=row.last_rotation_status,
)

def get_record_rotation(self, record_uid: str) -> Optional[pam_types.PamRecordRotationInfo]:
"""Rotation metadata for ``record_uid`` from the last vault / PAM rotation sync (or ``None``)."""
if not self._auth or not self._auth.auth_context.is_enterprise_admin:
"""Rotation metadata for ``record_uid`` from vault sync cache / PAM sqlite (or ``None``)."""
if not self._auth or not record_uid:
return None
if self._enterprise_loader is None:
if self._vault is not None:
info = self._vault.get_record_rotation(record_uid)
if info is not None:
return info
if (not self._auth.auth_context.is_enterprise_admin
or self._enterprise_loader is None):
return None
return self.pam_plugin.record_rotations.get_entity(record_uid)

Expand Down
45 changes: 41 additions & 4 deletions keepersdk-package/src/keepersdk/helpers/config_utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
from .. import crypto, utils
from ..errors import KeeperApiError
from ..proto import pam_pb2
from ..vault import vault_extensions, vault_online, vault_record
from .. import utils, crypto
from ..vault import nsf_management, vault_extensions, vault_online, vault_record

def pam_configuration_create_record_v6(vault: vault_online.VaultOnline, record: vault_record.TypedRecord, folder_uid: str):

def pam_configuration_create_record_v6(
vault: vault_online.VaultOnline,
record: vault_record.TypedRecord,
folder_uid: str) -> None:
"""Create a classic PAM configuration via pam/add_configuration_record."""
if not record.record_uid:
record.record_uid = utils.generate_uid()

Expand All @@ -22,6 +28,36 @@ def pam_configuration_create_record_v6(vault: vault_online.VaultOnline, record:
vault.keeper_auth.execute_auth_rest('pam/add_configuration_record', car)


def pam_configuration_create_record_nsf(
vault: vault_online.VaultOnline,
record: vault_record.TypedRecord,
folder_uid: str) -> None:
"""Create a PAM configuration in an NSF folder via vault/records/v3/add_pam_configuration."""
try:
nsf_management.create_nsf_pam_configuration(
vault, record, folder_uid, request_sync=True)
except nsf_management.NsfError as exc:
raise KeeperApiError('nsf_error', str(exc)) from exc


def create_pam_configuration_in_folder(
vault: vault_online.VaultOnline,
record: vault_record.TypedRecord,
folder_uid: str) -> bool:
"""Create a v6 PAM configuration in *folder_uid* using NSF or classic placement.

Returns True when the config was created as an NSF record (already placed in
the folder). Returns False for classic configs, which still need a move into
the shared folder after sync.
"""
if vault.nsf_data is not None and nsf_management.is_nsf_folder(vault, folder_uid):
pam_configuration_create_record_nsf(vault, record, folder_uid)
return True

pam_configuration_create_record_v6(vault, record, folder_uid)
return False


def configuration_controller_get(vault: vault_online.VaultOnline, config_uid_bytes: bytes):
"""
Get the Controller UID that has access to the configuration UID
Expand All @@ -31,7 +67,8 @@ def configuration_controller_get(vault: vault_online.VaultOnline, config_uid_byt
rq = pam_pb2.PAMGenericUidRequest()
rq.uid = config_uid_bytes

config_info_rs = vault.keeper_auth.execute_auth_rest('pam/get_configuration_controller', rq, response_type=pam_pb2.PAMController)
config_info_rs = vault.keeper_auth.execute_auth_rest(
'pam/get_configuration_controller', rq, response_type=pam_pb2.PAMController)

if config_info_rs:
return config_info_rs
Expand Down
66 changes: 33 additions & 33 deletions keepersdk-package/src/keepersdk/plugins/pam/pam_plugin.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from __future__ import annotations

import abc
from typing import Dict, List, Tuple
from typing import Iterable, List, Tuple

from . import pam_storage, pam_types
from ... import utils
from ...authentication.keeper_auth import KeeperAuth
from ...enterprise import enterprise_loader, sqlite_enterprise_storage
from ...proto import SyncDown_pb2, pam_pb2
from ...proto import pam_pb2
from ...storage import in_memory, storage_types


Expand Down Expand Up @@ -50,6 +50,12 @@ def sync_down(self, *, reload: bool = False) -> None:
def sync_record_rotations_from_vault(self) -> None:
pass

@abc.abstractmethod
def merge_record_rotations(
self, rows: Iterable[pam_storage.PamRecordRotation], *,
replace_all: bool = False) -> None:
pass

@property
@abc.abstractmethod
def controllers(self) -> storage_types.IEntityReader[pam_types.PamController, str]:
Expand Down Expand Up @@ -77,6 +83,8 @@ def __init__(self, loader: enterprise_loader.EnterpriseLoader):
self._controllers = in_memory.InMemoryEntityStorage[pam_types.PamController, str]()
self._record_rotations = in_memory.InMemoryEntityStorage[pam_types.PamRecordRotationInfo, str]()
self.logger = utils.get_logger()
# Load any rotations already persisted from a prior vault sync.
self.sync_record_rotations_from_vault()

@property
def controllers(self) -> storage_types.IEntityReader[pam_types.PamController, str]:
Expand All @@ -97,38 +105,33 @@ def _get_all_gateways(self, auth: KeeperAuth) -> List[pam_pb2.PAMController]:
return []

def sync_record_rotations_from_vault(self) -> None:

self._sync_record_rotations_from_vault_auth(self.loader.keeper_auth)

def _sync_record_rotations_from_vault_auth(self, auth: KeeperAuth) -> None:

merged: Dict[str, pam_storage.PamRecordRotation] = {}
rq = SyncDown_pb2.SyncDownRequest()
token = b''
done = False
while not done:
rq.continuationToken = token
response = auth.execute_auth_rest(
'vault/sync_down', rq, response_type=SyncDown_pb2.SyncDownResponse)
if response is None:
break
done = not response.hasMore
token = response.continuationToken or b''
for rr in response.recordRotations:
row = pam_storage.pam_record_rotation_from_proto(rr)
if row.record_uid:
merged[row.record_uid] = row
if not merged:
"""Reload rotations from PAM sqlite into memory (no vault/sync_down)."""
self._record_rotations.clear()
rows = list(self.storage.record_rotations.get_all_entities())
if rows:
self._record_rotations.put_entities(_pam_rotation_to_domain(r) for r in rows)

def merge_record_rotations(
self, rows: Iterable[pam_storage.PamRecordRotation], *,
replace_all: bool = False) -> None:
"""Upsert rotation rows into PAM sqlite + memory (from normal vault sync)."""
row_list = [r for r in rows if r and r.record_uid]
if replace_all:
existing = [r.uid() for r in self.storage.record_rotations.get_all_entities()]
if existing:
self.storage.record_rotations.delete_uids(existing)
self._record_rotations.clear()
if not row_list:
return
rows = list(merged.values())
self.storage.record_rotations.put_entities(rows)
self._record_rotations.put_entities(_pam_rotation_to_domain(r) for r in rows)
self.storage.record_rotations.put_entities(row_list)
self._record_rotations.put_entities(_pam_rotation_to_domain(r) for r in row_list)

def sync_down(self, *, reload: bool = False) -> None:
_ = reload
self.storage.reset()
existing = [c.uid() for c in self.storage.controllers.get_all_entities()]
if existing:
self.storage.controllers.delete_uids(existing)
self._controllers.clear()
self._record_rotations.clear()

auth = self.loader.keeper_auth
all_controllers = self._get_all_gateways(auth)
Expand All @@ -143,7 +146,4 @@ def sync_down(self, *, reload: bool = False) -> None:
if domain_rows:
self._controllers.put_entities(domain_rows)

try:
self._sync_record_rotations_from_vault_auth(auth)
except Exception as e:
self.logger.warning('PAM: loading record rotations from vault/sync_down failed: %s', e)
self.sync_record_rotations_from_vault()
35 changes: 34 additions & 1 deletion keepersdk-package/src/keepersdk/plugins/pam/pam_storage.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import abc
import sqlite3
from typing import Callable
from typing import Any, Callable, Dict, Optional

import attrs

Expand Down Expand Up @@ -59,6 +59,39 @@ def pam_record_rotation_from_proto(rr: SyncDown_pb2.RecordRotation) -> PamRecord
)


def pam_record_rotation_from_nsf_dict(data: Dict[str, Any]) -> Optional[PamRecordRotation]:
"""Build a rotation row from NSF ``recordRotationData`` JSON (MessageToDict shape)."""
if not isinstance(data, dict):
return None
record_uid = data.get('recordUid') or data.get('record_uid') or ''
if not record_uid:
return None
revision = data.get('revision', 0)
try:
revision = int(revision)
except (TypeError, ValueError):
revision = 0
pwd = data.get('pwdComplexity') or data.get('pwd_complexity') or b''
if isinstance(pwd, str):
try:
pwd = utils.base64_url_decode(pwd)
except Exception:
pwd = b''
return PamRecordRotation(
record_uid=str(record_uid),
revision=revision,
configuration_uid=str(
data.get('configurationUid') or data.get('configuration_uid') or ''),
schedule=str(data.get('schedule') or ''),
pwd_complexity=pwd if isinstance(pwd, (bytes, bytearray)) else b'',
disabled=bool(data.get('disabled', False)),
resource_uid=str(data.get('resourceUid') or data.get('resource_uid') or ''),
last_rotation=int(data.get('lastRotation') or data.get('last_rotation') or 0),
last_rotation_status=int(
data.get('lastRotationStatus') or data.get('last_rotation_status') or 0),
)


class IPamStorage(abc.ABC):
@property
@abc.abstractmethod
Expand Down
Loading