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
352 changes: 298 additions & 54 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
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
752 changes: 377 additions & 375 deletions keepersdk-package/src/keepersdk/proto/APIRequest_pb2.py

Large diffs are not rendered by default.

Loading