Skip to content
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
5 changes: 4 additions & 1 deletion docs/cyberark-pam-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ Every pamUser nested in a resource gets:

### Privilege Cloud (*.cyberark.cloud)
- OAuth2 service account via `POST /oauth2/platformtoken`
- Interactive user login (password + MFA) via CyberArk Identity `StartAuthentication` / `AdvanceAuthentication`
- SSO / external IdP via native-client `OobIdPAuth` (browser redirect + `/Security/OobAuthStatus` poll, or OOBAUTHPIN)
- Tenant ID discovery via `platform-discovery.cyberark.cloud`
- Tenant formats: `abc1234`, `mycompany`, `abc1234.id`, `tenant.my.idaptive.app`, full URL
- URL rewrite: `tenant.cyberark.cloud` → `tenant.privilegecloud.cyberark.cloud`
Expand All @@ -143,6 +145,7 @@ Every pamUser nested in a resource gets:
| KEEPER_CYBERARK_ID_TENANT | Identity tenant ID (Privilege Cloud) |
| KEEPER_CYBERARK_USERNAME | Username or service account client ID |
| KEEPER_CYBERARK_PASSWORD | Password or client secret |
| KEEPER_CYBERARK_AUTH_METHOD | `service` or `interactive` / `sso` (Privilege Cloud) |
| KEEPER_CYBERARK_LOGON_TYPE | Logon type for self-hosted (CyberArk/LDAP/RADIUS/Windows) |
| KEEPER_CYBERARK_SAFES | Comma-separated safe names |
| KEEPER_CYBERARK_SAFES_PATH | Path to safes.txt file |
Expand Down Expand Up @@ -256,7 +259,7 @@ Before building the import JSON, the importer warns about:
|---|---|---|
| Self-hosted PVWA (v10.4+) | CyberArk/LDAP/RADIUS/Windows | Implemented |
| Privilege Cloud (SaaS) | OAuth2 service account | Implemented |
| Privilege Cloud Shared Services (ISPSS) | OAuth2 with platform discovery | Implemented |
| Privilege Cloud Shared Services (ISPSS) | OAuth2 / interactive MFA / SSO (OobIdPAuth) | Implemented |
| User Portal (Identity) | Separate importer (cyberark_portal) | Different scope |

---
Expand Down
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: commander@keepersecurity.com
#

__version__ = '18.1.2'
__version__ = '18.1.3'
12 changes: 9 additions & 3 deletions keepercommander/importer/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ def register_command_info(aliases, command_info):
import_parser.add_argument('-s', '--shared', dest='shared', action='store_true',
help='import folders as Keeper shared folders')
import_parser.add_argument('--nsf', dest='use_nsf', action='store_true',
help='import folders and records into Nested Share Folders (works with any --format)')
help='import folders and records into Nested Share Folders '
'(json, csv, keepass, cyberark, cyberark_portal, …)')
import_parser.add_argument('-p', '--permissions', dest='permissions', action='store',
help='default shared folder permissions: manage (U)sers, manage (R)ecords, can (E)dit, can (S)hare, or (A)ll, (N)one')
import_parser.add_argument('--update', dest='update_flag', action='store_true',
Expand All @@ -82,7 +83,7 @@ def register_command_info(aliases, command_info):
import_parser.add_argument('--file-cache', dest='tmpdir', action='store',
help='temp directory used to cache encrypted attachment imports')
import_parser.add_argument('--show-skipped', dest='show_skipped', action='store_true',
help='Display skipped records')
help='display skipped records (always enabled for CyberArk imports)')
import_parser.add_argument('--secret-ids', dest='secret_ids', action='store',
help='Comma separated list of secret IDs to fetch (Thycotic)')
import_parser.add_argument('--target-node', '--node', dest='target_node', action='store',
Expand Down Expand Up @@ -173,6 +174,8 @@ def register_command_info(aliases, command_info):

Nested Share Folders (NSF):
import --format=csv --nsf sample_data/import_nsf.csv
import --format=cyberark --nsf https://tenant.privilegecloud.cyberark.cloud
import --format=cyberark_portal --nsf https://tenant.id.cyberark.cloud
'''

json_instructions = '''JSON Import Instructions
Expand All @@ -186,8 +189,11 @@ def register_command_info(aliases, command_info):
To load the sample file into your vault, run this command:
import --format=json sample_data/import.json.txt

Nested Share Folders (NSF) — works with any --format (json, csv, keepass, …):
Nested Share Folders (NSF) — works with any --format (json, csv, keepass,
cyberark, cyberark_portal, …):
import --format=json --nsf sample_data/import_nsf.txt
import --format=cyberark --nsf https://tenant.privilegecloud.cyberark.cloud
import --format=cyberark_portal --nsf https://tenant.id.cyberark.cloud

With --nsf, shared_folders[].permissions from JSON are granted on Nested
Share Folders after folders and records are created. Prefer NSF roles:
Expand Down
103 changes: 43 additions & 60 deletions keepercommander/importer/cyberark/cyberark.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from ..importer import (
BaseDownloadMembership,
BaseImporter,
Folder,
Permission,
Record,
RecordField,
Expand Down Expand Up @@ -260,6 +261,8 @@ def map_member(member):


class CyberArkImporter(BaseImporter):
verbose_import_summary = True

# Delay between requests to avoid hitting the API rate limits
DELAY = 0.025
# CyberArk REST API endpoints (relative to the base URL)
Expand Down Expand Up @@ -735,63 +738,20 @@ def _authenticate_pvwa(self, filename):
if not self._maybe_configure_client_cert(pvwa_host):
return None
if pvwa_host.endswith(".cyberark.cloud"):
pvwa_host = f"{pvwa_host.split('.')[0]}.privilegecloud.cyberark.cloud"
self._verify_tls = True
id_tenant = environ.get("_CYBERARK_ID_TENANT") or prompt("CyberArk Identity Tenant ID: ")
if re.match(r"^[A-Za-z]{3}\d{4}$", id_tenant):
id_tenant += ".id"
client_id = environ.get("_CYBERARK_USERNAME") or prompt("CyberArk service user name: ")
client_secret = environ.get("_CYBERARK_PASSWORD") or prompt(
"CyberArk service user password: ", is_password=True
)
token_url = f"https://{id_tenant}.cyberark.cloud/oauth2/platformtoken"
from .pam.client import CyberArkPVWAClient

try:
response = self._request(
"POST",
token_url,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
},
timeout=self.TIMEOUT,
)
except requests.exceptions.ConnectionError as e:
print_formatted_text(
HTML(
"OAuth2 authorization token request <ansired>failed</ansired>: "
f"could not connect to <b>{id_tenant}.cyberark.cloud</b>.\n"
"Verify the CyberArk Identity Tenant ID is correct (check the CyberArk Identity "
"Admin Portal URL — the first label of the hostname is your tenant ID) and that "
"your machine has network/DNS access to it."
)
)
print_formatted_text(HTML(f"<ansired>Details:</ansired> {e}"))
client = CyberArkPVWAClient(filename)
except ValueError as exc:
print_formatted_text(HTML(f"<ansired>{exc}</ansired>"))
return None
except requests.exceptions.RequestException as e:
print_formatted_text(
HTML(f"OAuth2 authorization token request <ansired>failed</ansired>: {e}")
)
if not client.authenticate():
return None
if response.status_code != 200:
print_formatted_text(
HTML(
f"OAuth2 authorization token request <ansired>failed</ansired> with status code <b>{response.status_code}</b>"
)
)
try:
print_formatted_text(HTML(f"<ansired>Response:</ansired> {response.text[:500]}"))
except Exception:
pass
return None
try:
access_token = response.json()["access_token"]
except (ValueError, KeyError) as e:
print_formatted_text(
HTML(f"OAuth2 response did not contain an access_token: <ansired>{e}</ansired>")
)
return None
authorization_token = f"Bearer {access_token}"
pvwa_host = client.pvwa_host
if client.query_params:
query_params.update(client.query_params)
authorization_token = client.auth_token
self._verify_tls = True
else:
login_type = environ.get("_CYBERARK_LOGON_TYPE") or prompt(
"CyberArk logon type (Cyberark, LDAP, RADIUS or Windows): "
Expand Down Expand Up @@ -825,7 +785,7 @@ def _authenticate_pvwa(self, filename):
)
return None
authorization_token = response.text.strip('"')
print_formatted_text(HTML("Log on <ansigreen>successful</ansigreen>"))
print_formatted_text(HTML("Log on <ansigreen>successful</ansigreen>"))
return pvwa_host, authorization_token, query_params

def _resolve_safes(self, pvwa_host, authorization_token):
Expand Down Expand Up @@ -979,6 +939,7 @@ def _do_import_inner(self, filename, **kwargs):
if auth is None:
return
pvwa_host, authorization_token, query_params = auth
use_nsf = bool(kwargs.get("use_nsf"))

params = kwargs.get("params")
will_teams = environ.get("_CYBERARK_SKIP_TEAMS", "").lower() not in ("1", "true", "yes")
Expand All @@ -994,7 +955,14 @@ def _do_import_inner(self, filename, **kwargs):
if not safes:
return


if use_nsf:
print_formatted_text(
HTML(
"\n<ansiyellow>NSF mode:</ansiyellow> CyberArk safes will be created as "
"Nested Share Folders and accounts as NSF records."
)
)

print_formatted_text(HTML("\nScanning CyberArk safes for accounts to migrate..."))
safe_accounts = {}
for safe in safes:
Expand Down Expand Up @@ -1093,6 +1061,10 @@ def _do_import_inner(self, filename, **kwargs):
f"\nYou are about to import data from CyberArk PVWA <b>{pvwa_host}</b> into Keeper:",
f" - <b>{total_accounts}</b> account(s) across <b>{len(safe_accounts)}</b> safe(s) as Keeper records",
]
if use_nsf:
summary_lines.append(
" - Safes as Nested Share Folders (--nsf); records created with the NSF API"
)
if group_names:
summary_lines.append(f" - <b>{len(group_names)}</b> user group(s) as Keeper teams and roles")
if eligible_users:
Expand Down Expand Up @@ -1123,12 +1095,23 @@ def _do_import_inner(self, filename, **kwargs):
tabulate([{"ID": x["id"], "Safe": x["safeName"], "Account": x["name"]} for x in accounts], headers="keys"),
end="\n\n",
)
if use_nsf:
# Explicit NSF folder so prepare_nsf_folders has a SharedFolder target;
# record folder paths also resolve to the same NSF node.
nsf_folder = SharedFolder()
nsf_folder.path = safe.replace(PathDelimiter, 2 * PathDelimiter)
yield nsf_folder
with _suppress_progressbar_executor_noise(), ProgressBar() as pb:
skip_all = {}
skipped_accounts = []
for r in pb(accounts, total=len(accounts)):
folder = SharedFolder()
folder.domain = r["safeName"]
folder = Folder()
# Classic import places each safe as a shared-folder domain.
# --nsf uses a path so Nested Share Folders are created instead.
if use_nsf:
folder.path = r["safeName"].replace(PathDelimiter, 2 * PathDelimiter)
else:
folder.domain = r["safeName"].replace(PathDelimiter, 2 * PathDelimiter)
record = Record()
record.folders = [folder]
record.title = re.sub(rf"^.*{re.escape(r['platformId'])}[\-_ ]", "", r["name"])
Expand All @@ -1155,7 +1138,7 @@ def _do_import_inner(self, filename, **kwargs):
"Authorization": authorization_token,
"Content-Type": "application/json",
},
json={"reason": "Keeper Commander Import"},
json={"reason": "test"},
timeout=self.TIMEOUT,
verify=True if pvwa_host.endswith(".cyberark.cloud") else self._verify_tls,
cert=None if pvwa_host.endswith(".cyberark.cloud") else self._client_cert,
Expand Down Expand Up @@ -2249,7 +2232,7 @@ def download_membership(self, params, **kwargs):

shared_folder = SharedFolder()
shared_folder.uid = str(safe.get("id") or safe_url_id)
shared_folder.path = safe_name
shared_folder.path = safe_name.replace(PathDelimiter, 2 * PathDelimiter)
shared_folder.permissions = []

skipped_service = 0
Expand Down
Loading
Loading