Skip to content
Merged

Release #1776

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b8f4c19
DR-1166 Add ability to select configuration UID for PAM
jwalstra-keeper Jan 7, 2026
11290ff
Fix discovery status for cancelled jobs.
jwalstra-keeper Jan 7, 2026
6f53161
Fix discovery job detail view; update keeper-dag code from main repo.
jwalstra-keeper Jan 8, 2026
3d5ddd8
Gov Dev server public key
sk-keeper Jan 8, 2026
f0bf312
Added `msp-update` `--name / -n` parameter for updating node, Fix msp…
pvagare-ks Jan 9, 2026
f2eab01
`pam launch` Establish ssh sessions with ssh key creds only (#1744)
idimov-keeper Jan 10, 2026
2b7ef9f
Fix compliance spinners and whoami expiration; add pytest (#1759)
aaunario-keeper Jan 13, 2026
1998d65
Fix MSP addon validations, `msp-update`, and seat handling consistenc…
pvagare-ks Jan 13, 2026
6b5169f
Fix --force flag (#1762)
pvagare-ks Jan 14, 2026
8b29863
Get the portForward port to use in tunneling
miroberts Jan 8, 2026
01adbd9
`msp-info` command enhancement (#1763)
pvagare-ks Jan 14, 2026
21aa120
2fa fix
pvagare-ks Jan 14, 2026
53062ed
Automate Docker-Service Mode & Slack app configuration (#1758) (#1768)
amangalampalli-ks Jan 15, 2026
89ee910
KC-1102: Fix compliance-report chunking for >5000 users (#1769)
aaunario-keeper Jan 15, 2026
0d0953c
Enterprise command improvements and bug fixes (#1766) (#1770)
pvagare-ks Jan 16, 2026
863e45d
Add cascade and node privileges to JSON enterprise role
lthievenaz-keeper Jan 15, 2026
6ac3d2b
Fix expression for cascade bool
lthievenaz-keeper Jan 15, 2026
0177b4e
DR-1173 Add configuration UID param to `pam action service` commands.
jwalstra-keeper Jan 20, 2026
350e8f2
Expand create-user scripts (#1749)
lthievenaz-keeper Jan 20, 2026
289f485
Add tunneling options to slack-app-setup and enhance service mode con…
amangalampalli-ks Jan 21, 2026
a7a8b75
Streamline enterprise/msp node, role, and team management and bug fix…
pvagare-ks Jan 21, 2026
60abbc6
KEPM: offline registration
sk-keeper Jan 15, 2026
8bedf98
KEPM: escalated approval status
sk-keeper Jan 22, 2026
e7c2a26
Release 17.2.5
sk-keeper Jan 22, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ dr-logs
CLAUDE.md
AGENTS.md
keeper_db.sqlite
.keeper-memory-mcp/
171 changes: 171 additions & 0 deletions examples/user_onboarding__create_and_login.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
''' _ __
| |/ /___ ___ _ __ ___ _ _ ®
| ' </ -_) -_) '_ \/ -_) '_|
|_|\_\___\___| .__/\___|_|
|_|

Keeper Commander
Description:

This script demonstrates how to automate the onboarding of user accounts in an enterprise, and connecting into each new vault via KeeperCommander for ultimate control over its content.

- If the email domain has not been reserved, the account creation will fail.

- If a user already exists for the email:
- and its status is invited, the account will be deleted and replaced with an active one (can be disabled with replace_invited param).
- and its status is active, the account creation is skipped but the program will still attempt to login by looking for a Master Password record on the admin vault.

- After actions are performed on the user vaults, their Master Password is expired again - which will prompt a new reset when the user logs in.

Usage:
- For a quick test, replace the emails for User_A and User_B below with valid emails for your enterprise.
The script runs import actions will expect a 'json_file.json' and 'csv_file.csv' in the current directory.
- For production, leverage the get_user_vault function to create and log into vaults, along with any KeeperCommander method to add content.
'''

USER_A = 'infra@disposable-domain.work.gd'
USER_B = 'devops@disposable-domain.work.gd'

from keepercommander.params import KeeperParams
from keepercommander import api
from keepercommander import cli
from keepercommander.loginv3 import LoginV3Flow
from keepercommander.commands.enterprise import EnterpriseUserCommand
eu = EnterpriseUserCommand()
login_v3_flow = LoginV3Flow()

def compile_users(params): # (KeeperParams) => list,list
api.query_enterprise(params)
active_usernames = [user['username'] for user in params.enterprise['users'] if user['status']!='invited']
invited_users = [user for user in params.enterprise['users'] if user['status']=='invited']

return active_usernames, invited_users


def generate_password(params,length=20): # (KeeperParams, int) => str
from keepercommander.generator import generate
import re
password_rules, min_iterations = login_v3_flow.get_default_password_rules(params)
while True:
password = generate(length)

failed_rules = []
for rule in password_rules:
pattern = re.compile(rule.pattern)
if not re.match(pattern, password):
failed_rules.append(rule.description)
if len(failed_rules) == 0:
return password


def get_user_vault(admin_params, user, folder=None, password_length=20, replace_invited=True): # (KeeperParams, dict, str, int, bool) => KeeperParams
'''
user_dict_format = {
'username': 'user@email.com'
'node_id': 1067368092533492, # Optional, also supports name
'full_name': 'Example Name', # Optional
'job_title': 'Example Job Title' # Optional
}
Folder must already exist in admin vault for folder flag
'''

from keepercommander.commands.enterprise_create_user import CreateEnterpriseUserCommand

if not user['username']:
print('get_user_vault function needs at least a username')
return
email = user['username']

# Get all users by status
active_usernames, invited_users = compile_users(admin_params)

# Delete invited (if allowed)
for invited_user in invited_users:
if invited_user['username'] == email:
print(f'Invited user for {email} found',end='')
if not replace_invited:
print(' - Not allowed to replace, could not create user.')
return
print(' - replacing...')
eu.execute(admin_params,email=[email],delete=True,force=True)
# replace empty user fields with that of found user
for key in ['node_id','full_name','job_title']:
if user.get(key,None) is None and invited_user.get(key,None) is not None:
user[key] = invited_user[key]

# Create user
user_record = None
if email not in active_usernames:
print(f'Creating user vault for {email}...')
record_uid = CreateEnterpriseUserCommand().execute(admin_params,email=email,node=user.get('node_id',None),name=user.get('full_name',None),folder=folder)
user_record = api.get_record(admin_params,record_uid)
eu.execute(admin_params,email=[email],jobtitle=user.get('job_title',None))
else:
print(f'Active user found for {email}. Could not create user, but will attempt to sign in using vault records.')
record_search = api.search_records(admin_params,f'Keeper Account: {email}')
if len(record_search)!=1:
print(f'Error looking up record with title "Keeper Account: {email}". Could not sign in as user.')
return
user_record = record_search[0]

if user_record is None:
print(f'Error looking up record with UID {record_uid}')
return

# Sign in as user
print(f'Signing in as user {email}...')
user_params = KeeperParams()
user_params.user = email
user_params.password = user_record.password

if email not in active_usernames:
# Reset tmp pwd
new_password = generate_password(admin_params)
login_v3_flow.login(user_params, new_password_if_reset_required=new_password)

# Update record password
user_params.password = new_password
from keepercommander.commands.record_edit import RecordUpdateCommand
RecordUpdateCommand().execute(admin_params, record=record_uid, fields=[f'password={new_password}'])

api.login(user_params)
api.sync_down(user_params)
print('Sign in Successful')
return user_params


# RUNTIME

# Login as admin
print('Signing in as admin...')
admin_params = KeeperParams()
admin_params.user = input('Admin email: ')
api.login(admin_params)
api.sync_down(admin_params)

# Create/get vault for User A (minimal example)
user_a_params = get_user_vault(admin_params,{'username':USER_A})
# Create/get vault for User B (extended example)
user_b_params = get_user_vault(
admin_params,
{
'username':USER_B,
'full_name': 'Jane Doe',
'job_title': 'DevOps Engineer'
},
folder='DevOps users'
)

# Run ad-hoc commands for User A
cli.do_command(user_a_params,'mkdir "Sample user folder" -uf')
cli.do_command(user_a_params,'record-add -rt login -t "Sample record" --folder "Sample user folder"')

from keepercommander.importer.imp_exp import _import as run_import
# Run CSV import for User A
run_import(user_a_params, 'csv', 'csv_file.csv')

# Run JSON import for User B
run_import(user_b_params, 'json', 'json_file.json')

# Re-expire Master Passwords
eu.execute(admin_params, email=[USER_A,USER_B], expire=True, force=True)
File renamed without changes.
2 changes: 1 addition & 1 deletion keepercommander/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@
# Contact: ops@keepersecurity.com
#

__version__ = '17.2.4'
__version__ = '17.2.5'
2 changes: 1 addition & 1 deletion keepercommander/command_categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
# Service Mode REST API
'Service Mode REST API': {
'service-create', 'service-add-config', 'service-start', 'service-stop', 'service-status',
'service-config-add'
'service-config-add', 'service-docker-setup', 'slack-app-setup'
},

# Email Configuration Commands
Expand Down
63 changes: 54 additions & 9 deletions keepercommander/commands/aram.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@
action_report_parser.add_argument('--days-since', '-d', dest='days_since', action='store', type=int,
help='number of days since event of interest (e.g., login, record add/update, lock)')
action_report_columns = {'name', 'status', 'transfer_status', 'node', 'team_count', 'teams', 'role_count', 'roles',
'alias', '2fa_enabled'}
'alias', '2fa_enabled', 'lock_time'}
columns_help = f'comma-separated list of columns to show on report. Supported columns: {action_report_columns}'
columns_help = re.sub('\'', '', columns_help)
action_report_parser.add_argument('--columns', dest='columns', action='store', type=str,
Expand Down Expand Up @@ -2100,6 +2100,33 @@ def get_no_action_users(candidate_users, days_since, event_types, name_key='user
excluded = get_excluded(included, query_filter, name_key)
return [user for user in candidate_users if user.get('username') not in excluded]

def chunk_list(items, chunk_size):
for i in range(0, len(items), chunk_size):
yield items[i:i + chunk_size]

def get_latest_lock_times(usernames):
# type: (Set[str]) -> Dict[str, int]
if not usernames:
return {}
lock_times = {}
username_list = sorted({u.lower() for u in usernames if u})
now_ts = int(datetime.datetime.now().timestamp())
for chunk in chunk_list(username_list, API_EVENT_SUMMARY_ROW_LIMIT):
query_filter = {
'audit_event_type': ['lock_user'],
'to_username': chunk,
'created': {'min': 0, 'max': now_ts}
}
rq = report_rq(query_filter, API_EVENT_SUMMARY_ROW_LIMIT, cols=['to_username'], report_type='span')
rs = api.communicate(params, rq)
events = rs.get('audit_event_overview_report_rows', [])
for event in events:
username = (event.get('to_username') or '').lower()
ts = int(event.get('last_created') or 0)
if username and ts:
lock_times[username] = max(lock_times.get(username, 0), ts)
return lock_times

def get_action_results_text(cmd, cmd_status, server_msg, affected):
return f'\tCOMMAND: {cmd}\n\tSTATUS: {cmd_status}\n\tSERVER MESSAGE: {server_msg}\n\tAFFECTED: {affected}'

Expand Down Expand Up @@ -2183,10 +2210,10 @@ def apply_admin_action(targets, status='no-update', action='none', dryrun=False)
'none': partial(run_cmd, targets, None, None, dryrun),
'lock': partial(run_cmd, targets,
lambda: exec_fn(params, email=emails, lock=True, force=True, return_results=True),
'lock', dry_run),
'lock', dryrun),
'delete': partial(run_cmd, targets,
lambda: exec_fn(params, email=emails, delete=True, force=True, return_results=True),
'delete', dry_run),
'delete', dryrun),
'transfer': partial(transfer_accounts, targets, kwargs.get('target_user'), dryrun)
}

Expand All @@ -2202,13 +2229,21 @@ def apply_admin_action(targets, status='no-update', action='none', dryrun=False)

return action_handlers.get(action, lambda: invalid_action_msg)() if is_valid_action else invalid_action_msg

def get_report_data_and_headers(targets, output_fmt):
# type: (Set[str], str) -> Tuple[List[List[Any]], List[str]]
def get_report_data_and_headers(targets, output_fmt, columns=None, lock_times=None):
# type: (Set[str], str, Optional[str], Optional[Dict[str, int]]) -> Tuple[List[List[Any]], List[str]]
cmd = EnterpriseInfoCommand()
output = cmd.execute(params, users=True, quiet=True, format='json', columns=kwargs.get('columns'))
output = cmd.execute(params, users=True, quiet=True, format='json', columns=columns)
data = json.loads(output)
data = [u for u in data if u.get('email') in targets]
fields = next(iter(data)).keys() if data else []
targets_lower = {t.lower() for t in targets if t}
data = [u for u in data if (u.get('email') or '').lower() in targets_lower]
if lock_times is not None:
for user in data:
email = (user.get('email') or '').lower()
lock_ts = lock_times.get(email)
user['lock_time'] = datetime.datetime.fromtimestamp(lock_ts) if lock_ts else None
fields = list(next(iter(data)).keys()) if data else []
if lock_times is not None and 'lock_time' not in fields:
fields.append('lock_time')
headers = [field_to_title(f) for f in fields] if output_fmt != 'json' else list(fields)
data = [[user.get(f) for f in fields] for user in data]
return data, headers
Expand Down Expand Up @@ -2305,10 +2340,20 @@ def get_descendant_nodes(node_id):
target_users = get_no_action_users(*args)
usernames = {user['username'] for user in target_users}

columns_arg = kwargs.get('columns')
columns = {c.strip().lower() for c in columns_arg.split(',') if c.strip()} if columns_arg else set()
include_lock_time = ('lock_time' in columns) if columns_arg else target_status == 'locked'
columns_param = None
if columns_arg:
columns_without_lock = [c for c in columns if c != 'lock_time']
if columns_without_lock:
columns_param = ','.join(columns_without_lock)

admin_action = kwargs.get('apply_action', 'none')
dry_run = kwargs.get('dry_run')
fmt = kwargs.get('format', 'table')
report_data, report_headers = get_report_data_and_headers(usernames, fmt)
lock_times = get_latest_lock_times(usernames) if include_lock_time else None
report_data, report_headers = get_report_data_and_headers(usernames, fmt, columns=columns_param, lock_times=lock_times)
action_msg = apply_admin_action(target_users, target_status, admin_action, dry_run)

# Sync local enterprise data if changes were made
Expand Down
Loading
Loading