Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DR-901 SaaS rotation commands #1389

Merged
merged 1 commit into from
Mar 10, 2025
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
2 changes: 1 addition & 1 deletion keepercommander/commands/discover/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def all_gateways(params: KeeperParams):
def from_configuration_uid(params: KeeperParams, configuration_uid: str, gateways: Optional[List] = None):

if gateways is None:
gateways = GatewayContext.all_gateways(KeeperParams)
gateways = GatewayContext.all_gateways(params)

configuration_record = vault.KeeperRecord.load(params, configuration_uid)
if not isinstance(configuration_record, vault.TypedRecord):
Expand Down
40 changes: 37 additions & 3 deletions keepercommander/commands/discoveryrotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
from ..subfolder import find_folders, find_parent_top_folder, \
try_resolve_path, BaseFolderNode
from ..vault import TypedField
from ..discovery_common.record_link import RecordLink
from .discover.job_start import PAMGatewayActionDiscoverJobStartCommand
from .discover.job_status import PAMGatewayActionDiscoverJobStatusCommand
from .discover.job_remove import PAMGatewayActionDiscoverJobRemoveCommand
Expand All @@ -78,6 +79,10 @@
from .pam_service.list import PAMActionServiceListCommand
from .pam_service.add import PAMActionServiceAddCommand
from .pam_service.remove import PAMActionServiceRemoveCommand
from .pam_saas.add import PAMActionSaasAddCommand
from .pam_saas.info import PAMActionSaasInfoCommand
from .pam_saas.remove import PAMActionSaasRemoveCommand
from .pam_saas.config import PAMActionSaasConfigCommand


def register_commands(commands):
Expand Down Expand Up @@ -185,6 +190,20 @@ def __init__(self):
self.default_verb = 'list'


class PAMActionSaasCommand(GroupCommand):

def __init__(self):
super(PAMActionSaasCommand, self).__init__()
self.register_command('info', PAMActionSaasInfoCommand(),
'Information of SaaS service rotation for a PAM User record.', 'i')
self.register_command('add', PAMActionSaasAddCommand(),
'Add a SaaS rotation to a PAM User record.', 'a')
self.register_command('remove', PAMActionSaasRemoveCommand(),
'Remove a SaaS rotation from a PAM User record', 'r')
self.register_command('config', PAMActionSaasConfigCommand(),
'Create a configuration for a SaaS rotation.', 'c')


class GatewayActionCommand(GroupCommand):

def __init__(self):
Expand All @@ -196,6 +215,8 @@ def __init__(self):
self.register_command('job-cancel', PAMGatewayActionJobCommand(), 'View Job details', 'jc')
self.register_command('service', PAMActionServiceCommand(),
'Manage services and scheduled tasks user mappings.', 's')
self.register_command('saas', PAMActionSaasCommand(),
'Manage user SaaS rotations.', 'sa')
self.register_command('debug', PAMDebugCommand(), 'PAM debug information')

# self.register_command('job-list', DRCmdListJobs(), 'List Running jobs')
Expand Down Expand Up @@ -2094,9 +2115,22 @@ def execute(self, params, **kwargs):
resource_uid = tmp_dag.get_resource_uid(record_uid)
if not resource_uid:
# NOOP records don't need resource_uid
noop_field = record.get_typed_field('text', 'NOOP')
noop = utils.value_to_boolean(noop_field.value[0]) if noop_field and noop_field.value else False
if not noop:

is_noop = False
pam_config = vault.KeeperRecord.load(params, config_uid)

# Check the graph for the noop setting.
record_link = RecordLink(record=pam_config, params=params, fail_on_corrupt=False)
acl = record_link.get_acl(record_uid, pam_config.record_uid)
if acl is not None and acl.rotation_settings is not None:
is_noop = acl.rotation_settings.noop

# If it was false in the graph, or did not exist, check the record.
if is_noop is False:
noop_field = record.get_typed_field('text', 'NOOP')
is_noop = utils.value_to_boolean(noop_field.value[0]) if noop_field and noop_field.value else False

if not is_noop:
print(f'{bcolors.FAIL}Resource UID not found for record [{record_uid}]. please configure it '
f'{bcolors.OKBLUE}"pam rotation user {record_uid} --resource RESOURCE_UID"{bcolors.ENDC}')
return
Expand Down
78 changes: 78 additions & 0 deletions keepercommander/commands/pam_saas/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from __future__ import annotations
import json
from ..pam.router_helper import router_send_action_to_gateway
from ..pam.pam_dto import GatewayAction
from ..pam.router_helper import get_response_payload
from ...proto import pam_pb2
from ...display import bcolors
import logging
from typing import Optional, List, TYPE_CHECKING

if TYPE_CHECKING:
from ..discover import GatewayContext
from ...params import KeeperParams


class GatewayActionSaasListCommandInputs:

def __init__(self,
configuration_uid: str,
languages: Optional[List[str]] = None):

if languages is None:
languages = ["en_US"]

self.configurationUid = configuration_uid
self.languages = languages

def toJSON(self):
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4)


class GatewayActionSaasListCommand(GatewayAction):

def __init__(self, inputs: GatewayActionSaasListCommandInputs, conversation_id=None):
super().__init__('saas-list', inputs=inputs, conversation_id=conversation_id, is_scheduled=True)

def toJSON(self):
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4)


def get_gateway_saas_schema(params: KeeperParams, gateway_context: GatewayContext):
if gateway_context is None:
print(f"{bcolors.FAIL}The user record does not have the set gateway{bcolors.ENDC}")
return

# Get schema information from the Gateway
action_inputs = GatewayActionSaasListCommandInputs(
configuration_uid=gateway_context.configuration_uid,
)

conversation_id = GatewayAction.generate_conversation_id()
router_response = router_send_action_to_gateway(
params=params,
gateway_action=GatewayActionSaasListCommand(
inputs=action_inputs,
conversation_id=conversation_id),
message_type=pam_pb2.CMT_GENERAL,
is_streaming=False,
destination_gateway_uid_str=gateway_context.gateway_uid
)

if router_response is None:
print(f"{bcolors.FAIL}Did not get router response.{bcolors.ENDC}")
return None

response = router_response.get("response")
logging.debug(f"Router Response: {response}")
payload = get_response_payload(router_response)
data = payload.get("data")
if data is None:
raise Exception("The router returned a failure.")
elif data.get("success") is False:
error = data.get("error")
logging.debug(f"gateway returned: {error}")
print(f"{bcolors.FAIL}Could not get a list of SaaS plugins available on the gateway.{bcolors.ENDC}")
return None

return data
171 changes: 171 additions & 0 deletions keepercommander/commands/pam_saas/add.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
from __future__ import annotations
import argparse
from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext
from ... import vault
from . import get_gateway_saas_schema
from keepercommander.discovery_common.record_link import RecordLink
from keepercommander.discovery_common.constants import PAM_USER, PAM_MACHINE, PAM_DATABASE, PAM_DIRECTORY
from keepercommander.discovery_common.types import UserAclRotationSettings
import json
from typing import Optional, TYPE_CHECKING

if TYPE_CHECKING:
from ...vault import TypedRecord
from ...params import KeeperParams


class PAMActionSaasAddCommand(PAMGatewayActionDiscoverCommandBase):
parser = argparse.ArgumentParser(prog='pam-action-saas-add')

parser.add_argument('--user-uid', '-u', required=True, dest='user_uid', action='store',
help='The UID of the User record')
parser.add_argument('--config-record-uid', '-c', required=True, dest='config_record_uid',
action='store', help='The UID of the record that has SaaS configuration')
parser.add_argument('--resource-uid', '-r', required=False, dest='resource_uid', action='store',
help='The UID of the Resource record, if needed.')

def get_parser(self):
return PAMActionSaasAddCommand.parser

def execute(self, params: KeeperParams, **kwargs):

user_uid = kwargs.get("user_uid") # type: str
resource_uid = kwargs.get("resource_uid") # type: str
config_record_uid = kwargs.get("config_record_uid") # type: str

print("")

# Check to see if the record exists.
user_record = vault.KeeperRecord.load(params, user_uid) # type: Optional[TypedRecord]
if user_record is None:
print(self._f("The user record does not exists."))
return

# Make sure this user is a PAM User.
if user_record.record_type != PAM_USER:
print(self._f("The user record is not a PAM User."))
return

record_rotation = params.record_rotation_cache.get(user_record.record_uid)
if record_rotation is not None:
configuration_uid = record_rotation.get("configuration_uid")
else:
print(self._f("The user record does not have any rotation settings."))
return

if configuration_uid is None:
print(self._f("The user record does not have the configuration record set in the rotation settings."))
return

gateway_context = GatewayContext.from_configuration_uid(params, configuration_uid)

if gateway_context is None:
print(self._f("The user record does not have the set gateway"))
return

schema_data = get_gateway_saas_schema(params, gateway_context)
if schema_data is None:
return

# Check to see if the config record exists.
config_record = vault.KeeperRecord.load(params, config_record_uid) # type: Optional[TypedRecord]
if config_record is None:
print(self._f("The SaaS configuration record does not exists."))
return

# Make sure this config is a Login record.

if config_record.record_type != "login":
print(self._f("The SaaS configuration record is not a Login record."))
return

saas_type_field = next((x for x in config_record.custom if x.label == "SaaS Type"), None)
if saas_type_field is None:
print(self._f("The SaaS configuration record is missing the custom field label 'SaaS Type'"))
return

saas_type = None
if saas_type_field.value is not None and len(saas_type_field.value) > 0:
saas_type = saas_type_field.value[0]

if saas_type is None:
print(self._f("The SaaS configuration record's custom field label 'SaaS Type' does not have a value."))
return

found_plugin = False
for plugin in schema_data.get("data", []):
if plugin["name"] == saas_type:
found_plugin = True
missing_fields = []
for field in plugin["schema"]:
if field.get("required") is True:
found = next((x for x in config_record.custom if x.label == field.get("label")), None)
if not found:
missing_fields.append(field.get("label").strip())

if len(missing_fields) > 0:
print(self._f("The SaaS configuration record is missing the following required custom fields: "
f'{", ".join(missing_fields)}'))
return

if found_plugin is False:
print(self._f("The SaaS configuration record's custom field label 'SaaS Type' is not supported by the "
"gateway or the value is not correct."))
return

parent_uid = gateway_context.configuration_uid

# Not sure if SaaS type rotation should be limited to NOOP rotation.
# Allow a resource record to be used.
if resource_uid is not None:
# Check to see if the record exists.
resource_record = vault.KeeperRecord.load(params, resource_uid) # type: Optional[TypedRecord]
if resource_record is None:
print(self._f("The resource record does not exists."))
return

# Make sure this user is a PAM User.
if user_record.record_type in [PAM_MACHINE, PAM_DATABASE, PAM_DIRECTORY]:
print(self._f("The resource record does not have the correct record type."))
return

parent_uid = resource_uid

record_link = RecordLink(record=gateway_context.configuration, params=params, fail_on_corrupt=False)
acl = record_link.get_acl(user_uid, parent_uid)
if acl is None:
if resource_uid is not None:
print(self._f("There is no relationship between the user and the resource record."))
else:
print(self._f("There is no relationship between the user and the configuration record."))
return

if acl.rotation_settings is None:
acl.rotation_settings = UserAclRotationSettings()

if resource_uid is not None and acl.rotation_settings.noop is True:
print(self._f("The rotation is flagged as No Operation, however you passed in a resource record. "
"This combination is not allowed."))
return

# If there is a resource record, it not NOOP.
# If there is NO resource record, it is NOOP.\
# However, if this is an IAM User, don't set the NOOP
if acl.is_iam_user is False:
acl.rotation_settings.noop = resource_uid is None

# PyCharm didn't like appending directly, so do this stupid thing.
record_uid_list = acl.rotation_settings.saas_record_uid_list

# Make sure we are not re-adding the same SaaS config.
if config_record_uid in record_uid_list:
print(self._f("The SaaS configuration record is already being used for this user."))
return

record_uid_list.append(config_record_uid)
acl.rotation_settings.saas_record_uid_list = record_uid_list

record_link.belongs_to(user_uid, parent_uid, acl=acl)
record_link.save()

print(self._gr(f"Added {saas_type} service rotation to the user record."))
Loading
Loading