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
3 changes: 2 additions & 1 deletion keepercommander/command_categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@
# Service Mode REST API
'Service Mode REST API': {
'service-create', 'service-add-config', 'service-start', 'service-stop', 'service-status',
'service-config-add', 'service-docker-setup', 'slack-app-setup', 'teams-app-setup'
'service-config-add', 'service-docker-setup', 'slack-app-setup', 'teams-app-setup',
'sailpoint-app-setup'
},

# Email Configuration Commands
Expand Down
8 changes: 6 additions & 2 deletions keepercommander/commands/start_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
from ..service.commands.config_operation import AddConfigService
from ..service.commands.handle_service import StartService, StopService, ServiceStatus
from ..service.commands.service_docker_setup import ServiceDockerSetupCommand
from ..service.commands.integrations import SlackAppSetupCommand, TeamsAppSetupCommand
from ..service.commands.integrations import (
SlackAppSetupCommand, TeamsAppSetupCommand, SailPointAppSetupCommand,
)

def register_commands(commands):
commands['service-create'] = CreateService()
Expand All @@ -24,6 +26,7 @@ def register_commands(commands):
commands['service-docker-setup'] = ServiceDockerSetupCommand()
commands['slack-app-setup'] = SlackAppSetupCommand()
commands['teams-app-setup'] = TeamsAppSetupCommand()
commands['sailpoint-app-setup'] = SailPointAppSetupCommand()

def register_command_info(aliases, command_info):
service_classes = [
Expand All @@ -34,7 +37,8 @@ def register_command_info(aliases, command_info):
ServiceStatus,
ServiceDockerSetupCommand,
SlackAppSetupCommand,
TeamsAppSetupCommand
TeamsAppSetupCommand,
SailPointAppSetupCommand,
]

for service_class in service_classes:
Expand Down
5 changes: 5 additions & 0 deletions keepercommander/service/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from flask import Flask, jsonify
import logging
import os
from werkzeug.middleware.proxy_fix import ProxyFix
from flask_limiter.errors import RateLimitExceeded
from .decorators.security import limiter, is_behind_proxy
Expand Down Expand Up @@ -46,6 +47,10 @@ def handle_rate_limit_exceeded(e):
logger.debug("Initializing API routes")
init_routes(app)

if (os.environ.get('SAILPOINT_RECORD') or '').strip():
from .commands.integrations.sailpoint.service import SailPointService
SailPointService.start_background_services()

print("Keeper Commander Service initialization complete")
return app

Expand Down
13 changes: 12 additions & 1 deletion keepercommander/service/commands/create_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#

import argparse
import os
from typing import Any, Dict, Optional
from ..config.service_config import ServiceConfig
from ..config.config_validation import ValidationError
Expand Down Expand Up @@ -92,9 +93,19 @@ def execute(self, params: KeeperParams, **kwargs) -> None:
from ..core.globals import init_globals
init_globals(params)

filtered_kwargs = {k: v for k, v in kwargs.items() if k in ['port', 'allowedip', 'deniedip', 'commands', 'ngrok', 'ngrok_custom_domain', 'cloudflare', 'cloudflare_custom_domain', 'certfile', 'certpassword', 'fileformat', 'run_mode', 'queue_enabled', 'update_vault_record', 'ratelimit', 'encryption', 'encryption_key', 'token_expiration']}
filtered_kwargs = {k: v for k, v in kwargs.items() if k in [
'port', 'allowedip', 'deniedip', 'commands', 'ngrok', 'ngrok_custom_domain',
'cloudflare', 'cloudflare_custom_domain', 'certfile', 'certpassword', 'fileformat',
'run_mode', 'queue_enabled', 'update_vault_record', 'ratelimit', 'encryption',
'encryption_key', 'token_expiration',
]}
args = StreamlineArgs(**filtered_kwargs)

# Optional SailPoint: enable when SAILPOINT_RECORD points at a marked config record
if (os.environ.get('SAILPOINT_RECORD') or '').strip():
from .integrations.sailpoint.service import SailPointService
SailPointService.maybe_enable(params, args)

from .integrations.vault_metadata import get_existing_api_key, write_service_metadata
existing_api_key = (
get_existing_api_key(params, args.update_vault_record)
Expand Down
2 changes: 2 additions & 0 deletions keepercommander/service/commands/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
from .integration_setup_base import IntegrationSetupCommand
from .slack_app_setup import SlackAppSetupCommand
from .teams_app_setup import TeamsAppSetupCommand
from .sailpoint_app_setup import SailPointAppSetupCommand

__all__ = [
'IntegrationSetupCommand',
'SlackAppSetupCommand',
'TeamsAppSetupCommand',
'SailPointAppSetupCommand',
]
Original file line number Diff line number Diff line change
Expand Up @@ -498,8 +498,16 @@ def _print_integration_resources(self, record_uid: str, config) -> None:
name = self.get_integration_name()
print(f" • {name} Config Record: {bcolors.OKBLUE}{record_uid}{bcolors.ENDC}")
self.print_integration_specific_resources(config)
print(f" • EPM Integration: {bcolors.OKBLUE}{'true' if config.pedm_enabled else 'false'}{bcolors.ENDC}")
print(f" • Device Approval: {bcolors.OKBLUE}{'true' if config.device_approval_enabled else 'false'}{bcolors.ENDC}")
if hasattr(config, 'pedm_enabled'):
print(
f" • EPM Integration: "
f"{bcolors.OKBLUE}{'true' if config.pedm_enabled else 'false'}{bcolors.ENDC}"
)
if hasattr(config, 'device_approval_enabled'):
print(
f" • Device Approval: "
f"{bcolors.OKBLUE}{'true' if config.device_approval_enabled else 'false'}{bcolors.ENDC}"
)

# -- Optional feature collectors -----------------------------------

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# _ __
# | |/ /___ ___ _ __ ___ _ _ ®
# | ' </ -_) -_) '_ \/ -_) '_|
# |_|\_\___\___| .__/\___|_|
# |_|
#
# Keeper Commander
# Copyright 2026 Keeper Security Inc.
# Contact: commander@keepersecurity.com
#

"""SailPoint Service Mode integration package."""

from .command_policy import SailPointCommandPolicy
from .constants import DOCKER_RECORD_ENV, PARAMS_ATTR, SAILPOINT_ALLOWED_COMMANDS, SAILPOINT_RECORD_ENV


def __getattr__(name):
if name == 'SailPointService':
from .service import SailPointService
return SailPointService
raise AttributeError(f'module {__name__!r} has no attribute {name!r}')


__all__ = [
'DOCKER_RECORD_ENV',
'SAILPOINT_RECORD_ENV',
'PARAMS_ATTR',
'SAILPOINT_ALLOWED_COMMANDS',
'SailPointCommandPolicy',
'SailPointService',
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
# _ __
# | |/ /___ ___ _ __ ___ _ _ ®
# | ' </ -_) -_) '_ \/ -_) '_|
# |_|\_\___\___| .__/\___|_|
# |_|
#
# Keeper Commander
# Copyright 2026 Keeper Security Inc.
# Contact: commander@keepersecurity.com
#

"""Apply pending SailPoint entitlements once a user is Active."""

from __future__ import annotations

import shlex
from typing import Any, Dict, List, Tuple

from .....params import KeeperParams
from ....decorators.logging import logger
from .pending_store import SailPointPendingStore
from .scim_guard import SailPointScimGuard

_NOT_FOUND_HINTS = ('not found', 'no such', 'does not exist')


class SailPointEntitlementApplier:
"""Apply queued roles/teams/shares after a user becomes Active."""

@staticmethod
def _role_exists(params: KeeperParams, role_name: str) -> bool:
return any(
str(role.get('role_id')) == role_name
or ((role.get('data') or {}).get('displayname') or '').lower() == role_name.lower()
for role in params.enterprise.get('roles') or []
)

@staticmethod
def _team_exists(params: KeeperParams, team_name: str) -> bool:
return any(
team.get('team_uid') == team_name
or (team.get('name') or '').lower() == team_name.lower()
for team in params.enterprise.get('teams') or []
)

@staticmethod
def _run(params: KeeperParams, command: str) -> None:
from ..... import cli
logger.info(f'SailPoint apply: {command}')
cli.do_command(params, command)

@staticmethod
def _is_missing_target(error: Exception) -> bool:
msg = str(error).lower()
return any(h in msg for h in _NOT_FOUND_HINTS)

@staticmethod
def _shell_quote(value: str) -> str:
return shlex.quote(str(value))

@classmethod
def user_is_active(cls, params: KeeperParams, email: str) -> bool:
user = SailPointScimGuard.find_user(params, email)
return bool(user and user.get('status') == 'active')

@classmethod
def _apply_folder(cls, params: KeeperParams, email: str, folder: Dict[str, Any]) -> None:
uid = folder.get('uid')
if not uid:
raise ValueError('Folder entry missing uid')
kind = (folder.get('kind') or 'classic').lower()
email_q = cls._shell_quote(email)
uid_q = cls._shell_quote(uid)
if kind == 'nsf':

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it will not impact case sensitivity I believe
kind == 'nsf':

role = folder.get('role') or 'viewer'
cls._run(
params,
f'nsf-share-folder -a grant -e {email_q} -r {cls._shell_quote(role)} {uid_q}',
)
return

flags = []
manage_records = folder.get('manage_records')
manage_users = folder.get('manage_users')
if manage_records in ('on', 'off'):
flags.append(f'--manage-records {manage_records}')
if manage_users in ('on', 'off'):
flags.append(f'--manage-users {manage_users}')
flag_str = (' ' + ' '.join(flags)) if flags else ''
cls._run(params, f'share-folder -a grant --email {email_q}{flag_str} {uid_q}')

@classmethod
def _apply_record(cls, params: KeeperParams, email: str, record: Dict[str, Any]) -> None:
uid = record.get('uid')
if not uid:
raise ValueError('Record entry missing uid')
kind = (record.get('kind') or 'classic').lower()
email_q = cls._shell_quote(email)
uid_q = cls._shell_quote(uid)
if kind == 'nsf':
role = record.get('role') or 'viewer'
cls._run(
params,
f'nsf-share-record -a grant -e {email_q} -r {cls._shell_quote(role)} {uid_q}',
)
return

flags = []
if record.get('can_edit'):
flags.append('--write')
if record.get('can_share'):
flags.append('--share')
flag_str = (' ' + ' '.join(flags)) if flags else ''

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the purpose of + here?
what if it is like
flag_str = f" {' '.join(flags)}" if flags else ""

cls._run(params, f'share-record --email {email_q}{flag_str} {uid_q}')

@classmethod
def apply_for_user(
cls,
params: KeeperParams,
email: str,
entry: Dict[str, Any],
*,
entitlement_scope: str = 'both',
) -> Tuple[Dict[str, Any], List[str]]:
remaining = {
'created_at': entry.get('created_at'),
'last_error': None,
'roles': list(entry.get('roles') or []),
'teams': list(entry.get('teams') or []),
'folders': [dict(x) for x in (entry.get('folders') or [])],
'records': [dict(x) for x in (entry.get('records') or [])],
}
dropped: List[str] = []
scim_user = SailPointScimGuard.is_scim_managed_user(params, email)
email_q = cls._shell_quote(email)

if scim_user:
if remaining['roles'] or remaining['teams']:
dropped.append(
f'SCIM-managed user {email}: skipped pending roles/teams (identity coexistence)'
)
remaining['roles'] = []
remaining['teams'] = []
else:
still_roles = []
for role in remaining['roles']:
if not cls._role_exists(params, role):
dropped.append(f'Role not found, dropped: {role}')
continue
try:
cls._run(params, f'enterprise-user {email_q} --add-role {cls._shell_quote(role)}')
except Exception as e:
logger.warning(f'Failed to add role {role} for {email}: {e}')
still_roles.append(role)
remaining['last_error'] = str(e)
remaining['roles'] = still_roles

still_teams = []
for team in remaining['teams']:
if not cls._team_exists(params, team):
dropped.append(f'Team not found, dropped: {team}')
continue
try:
cls._run(params, f'enterprise-user {email_q} --add-team {cls._shell_quote(team)}')
except Exception as e:
logger.warning(f'Failed to add team {team} for {email}: {e}')
still_teams.append(team)
remaining['last_error'] = str(e)
remaining['teams'] = still_teams

allow_folders = entitlement_scope in ('folders', 'both')
allow_records = entitlement_scope in ('records', 'both')

still_folders = []
if allow_folders:
for folder in remaining['folders']:
uid = folder.get('uid')
if not uid:
dropped.append('Folder entry missing uid, dropped')
continue
try:
cls._apply_folder(params, email, folder)
except Exception as e:
if cls._is_missing_target(e):
dropped.append(f'Folder not found, dropped: {uid}')
else:
logger.warning(f'Failed to share folder {uid} with {email}: {e}')
still_folders.append(folder)
remaining['last_error'] = str(e)
remaining['folders'] = still_folders
elif remaining['folders']:
dropped.append('Folder shares skipped by entitlement_scope')
remaining['folders'] = []

still_records = []
if allow_records:
for record in remaining['records']:
uid = record.get('uid')
if not uid:
dropped.append('Record entry missing uid, dropped')
continue
try:
cls._apply_record(params, email, record)
except Exception as e:
if cls._is_missing_target(e):
dropped.append(f'Record not found, dropped: {uid}')
else:
logger.warning(f'Failed to share record {uid} with {email}: {e}')
still_records.append(record)
remaining['last_error'] = str(e)
remaining['records'] = still_records
elif remaining['records']:
dropped.append('Record shares skipped by entitlement_scope')
remaining['records'] = []

if SailPointPendingStore.entry_is_empty(remaining):
remaining = {}
return remaining, dropped
Loading