ark-sdk-python 2.1.4__py3-none-any.whl → 2.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ark_sdk_python/actions/ark_exec_action.py +1 -0
- ark_sdk_python/ark_api.py +60 -0
- ark_sdk_python/auth/ark_auth.py +6 -1
- ark_sdk_python/auth/identity/ark_identity_fqdn_resolver.py +4 -4
- ark_sdk_python/common/ark_version.py +1 -1
- ark_sdk_python/common/isp/ark_isp_service_client.py +3 -1
- ark_sdk_python/examples/license_consumption_analytics.py +1342 -0
- ark_sdk_python/examples/validate_ssh_connection.py +11 -4
- ark_sdk_python/models/actions/services/__init__.py +6 -0
- ark_sdk_python/models/actions/services/ark_access_exec_action_consts.py +20 -0
- ark_sdk_python/models/actions/services/ark_ars_exec_action_consts.py +40 -0
- ark_sdk_python/models/actions/services/ark_cmgr_exec_action_consts.py +45 -1
- ark_sdk_python/models/actions/services/ark_identity_exec_action_consts.py +17 -0
- ark_sdk_python/models/actions/services/ark_pcloud_exec_action_consts.py +45 -0
- ark_sdk_python/models/actions/services/ark_sia_exec_action_consts.py +80 -4
- ark_sdk_python/models/common/ark_application_code.py +5 -0
- ark_sdk_python/models/common/ark_os_type.py +1 -0
- ark_sdk_python/models/common/ark_region.py +2 -1
- ark_sdk_python/models/common/identity/ark_identity_directory_schemas.py +2 -0
- ark_sdk_python/models/services/access/__init__.py +15 -0
- ark_sdk_python/models/services/access/assets/__init__.py +13 -0
- ark_sdk_python/models/services/access/assets/ark_access_assets_asset.py +61 -0
- ark_sdk_python/models/services/access/assets/ark_access_assets_assets.py +15 -0
- ark_sdk_python/models/services/access/assets/ark_access_assets_list_assets.py +32 -0
- ark_sdk_python/models/services/access/assets/ark_access_assets_secret.py +15 -0
- ark_sdk_python/models/services/ars/__init__.py +0 -0
- ark_sdk_python/models/services/ars/common/__init__.py +9 -0
- ark_sdk_python/models/services/ars/common/ark_ars_filter.py +14 -0
- ark_sdk_python/models/services/ars/common/ark_ars_query.py +22 -0
- ark_sdk_python/models/services/ars/requests/__init__.py +31 -0
- ark_sdk_python/models/services/ars/requests/ark_ars_access_request.py +82 -0
- ark_sdk_python/models/services/ars/requests/ark_ars_access_request_async_task.py +44 -0
- ark_sdk_python/models/services/ars/requests/ark_ars_access_request_form.py +67 -0
- ark_sdk_python/models/services/ars/requests/ark_ars_access_request_info.py +23 -0
- ark_sdk_python/models/services/ars/requests/ark_ars_access_requests_stats.py +17 -0
- ark_sdk_python/models/services/ars/requests/ark_ars_cancel_access_request.py +8 -0
- ark_sdk_python/models/services/ars/requests/ark_ars_finalize_access_request.py +20 -0
- ark_sdk_python/models/services/ars/requests/ark_ars_get_access_request.py +7 -0
- ark_sdk_python/models/services/ars/requests/ark_ars_list_access_request_forms.py +12 -0
- ark_sdk_python/models/services/ars/requests/ark_ars_request_type.py +6 -0
- ark_sdk_python/models/services/ars/requests/ark_ars_submit_access_request.py +18 -0
- ark_sdk_python/models/services/ars/requests/ark_target_category.py +32 -0
- ark_sdk_python/models/services/cmgr/__init__.py +59 -3
- ark_sdk_python/models/services/cmgr/ark_cmgr_component.py +82 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_component_operation_async_task.py +31 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_components_filter.py +16 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_components_stats.py +15 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_connector.py +52 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_connector_components_filter.py +7 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_connector_setup_script.py +8 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_connectors_filter.py +34 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_connectors_stats.py +31 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_delete_connector.py +24 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_get_connector.py +7 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_get_connector_component.py +8 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_get_connector_setup_script.py +23 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_install_component.py +12 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_install_connector.py +28 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_list_connector_components.py +7 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_reinstall_component.py +8 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_rescan_components.py +7 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_uninstall_component.py +16 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_uninstall_connector.py +21 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_upgrade_component.py +9 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_upgrade_connector.py +7 -0
- ark_sdk_python/models/services/cmgr/ark_cmgr_workspace_type_serializer.py +18 -0
- ark_sdk_python/models/services/identity/directories/ark_identity_directory.py +4 -0
- ark_sdk_python/models/services/identity/federations/__init__.py +15 -0
- ark_sdk_python/models/services/identity/federations/ark_identity_federation.py +75 -0
- ark_sdk_python/models/services/identity/federations/ark_identity_federations_filter.py +10 -0
- ark_sdk_python/models/services/identity/federations/ark_identity_get_federation.py +7 -0
- ark_sdk_python/models/services/identity/users/__init__.py +8 -0
- ark_sdk_python/models/services/identity/users/ark_identity_get_user_state.py +17 -0
- ark_sdk_python/models/services/identity/users/ark_identity_list_user_roles.py +16 -0
- ark_sdk_python/models/services/identity/users/ark_identity_user_role.py +12 -0
- ark_sdk_python/models/services/identity/users/ark_identity_user_state.py +20 -0
- ark_sdk_python/models/services/pcloud/usermgmt/__init__.py +65 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_activate_vault_user.py +7 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_add_vault_group.py +5 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_add_vault_member_to_group.py +15 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_add_vault_user.py +18 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_delete_vault_group.py +7 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_delete_vault_user.py +7 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_disable_vault_user.py +7 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_enable_vault_user.py +7 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_get_vault_group.py +7 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_get_vault_user.py +7 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_remove_vault_member_from_group.py +10 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_update_vault_group.py +10 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_update_vault_user.py +18 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_vault_group.py +22 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_vault_groups_filter.py +12 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_vault_groups_stats.py +10 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_vault_user.py +96 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_vault_users_filter.py +22 -0
- ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_vault_users_stats.py +12 -0
- ark_sdk_python/models/services/sia/access/__init__.py +54 -0
- ark_sdk_python/models/services/sia/access/ark_sia_connector.py +119 -0
- ark_sdk_python/models/services/sia/access/ark_sia_connectors_filter.py +39 -0
- ark_sdk_python/models/services/sia/access/ark_sia_connectors_stats.py +33 -0
- ark_sdk_python/models/services/sia/access/ark_sia_delete_connector.py +25 -0
- ark_sdk_python/models/services/sia/access/ark_sia_delete_https_relay.py +31 -0
- ark_sdk_python/models/services/sia/access/ark_sia_get_connector.py +7 -0
- ark_sdk_python/models/services/sia/access/ark_sia_get_connector_setup_script.py +4 -0
- ark_sdk_python/models/services/sia/access/ark_sia_get_https_relay.py +7 -0
- ark_sdk_python/models/services/sia/access/ark_sia_get_https_relay_setup_script.py +19 -0
- ark_sdk_python/models/services/sia/access/ark_sia_https_relay.py +34 -0
- ark_sdk_python/models/services/sia/access/ark_sia_https_relay_setup_script.py +8 -0
- ark_sdk_python/models/services/sia/access/ark_sia_https_relay_status_codes.py +14 -0
- ark_sdk_python/models/services/sia/access/ark_sia_https_relays_filter.py +13 -0
- ark_sdk_python/models/services/sia/access/ark_sia_install_connector.py +13 -2
- ark_sdk_python/models/services/sia/access/ark_sia_install_relay.py +32 -0
- ark_sdk_python/models/services/sia/access/ark_sia_k8s_details.py +36 -0
- ark_sdk_python/models/services/sia/access/ark_sia_rotate_connector.py +7 -0
- ark_sdk_python/models/services/sia/access/ark_sia_rotate_https_relay.py +7 -0
- ark_sdk_python/models/services/sia/access/ark_sia_uninstall_connector.py +15 -3
- ark_sdk_python/models/services/sia/access/ark_sia_uninstall_relay.py +23 -0
- ark_sdk_python/models/services/sia/access/ark_sia_update_connector_maintenance_mode.py +23 -0
- ark_sdk_python/models/services/sia/access/ark_sia_upgrade_connector.py +7 -0
- ark_sdk_python/models/services/sia/access/ark_sia_upgrade_connector_async_task.py +32 -0
- ark_sdk_python/models/services/sia/access/ark_sia_upgrade_https_relay.py +7 -0
- ark_sdk_python/models/services/sia/k8s/__init__.py +11 -1
- ark_sdk_python/models/services/sia/k8s/ark_sia_k8s_cluster.py +22 -0
- ark_sdk_python/models/services/sia/k8s/ark_sia_k8s_clusters.py +10 -0
- ark_sdk_python/models/services/sia/k8s/ark_sia_k8s_generate_cluster_config.py +13 -0
- ark_sdk_python/models/services/sia/k8s/ark_sia_k8s_list_clusters.py +9 -0
- ark_sdk_python/models/services/sia/secrets/db/__init__.py +22 -6
- ark_sdk_python/models/services/sia/secrets/db/ark_sia_db_add_secret.py +54 -3
- ark_sdk_python/models/services/sia/secrets/db/ark_sia_db_delete_secret.py +4 -0
- ark_sdk_python/models/services/sia/secrets/db/ark_sia_db_get_secret.py +4 -0
- ark_sdk_python/models/services/sia/secrets/db/ark_sia_db_list_strong_accounts_response.py +16 -0
- ark_sdk_python/models/services/sia/secrets/db/ark_sia_db_secret_metadata.py +27 -2
- ark_sdk_python/models/services/sia/secrets/db/ark_sia_db_secrets_filter.py +7 -0
- ark_sdk_python/models/services/sia/secrets/db/ark_sia_db_strong_accounts_filter.py +29 -0
- ark_sdk_python/models/services/sia/secrets/db/ark_sia_db_update_secret.py +51 -3
- ark_sdk_python/models/services/sia/settings/__init__.py +2 -0
- ark_sdk_python/models/services/sia/settings/ark_sia_settings_feature_name.py +1 -0
- ark_sdk_python/models/services/sia/settings/ark_sia_settings_https_relay.py +11 -0
- ark_sdk_python/models/services/sia/settings/ark_sia_settings_list_settings.py +4 -1
- ark_sdk_python/models/services/sia/settings/ark_sia_settings_set_settings.py +2 -0
- ark_sdk_python/models/services/sia/settings/ark_sia_settings_setting_types.py +2 -0
- ark_sdk_python/models/services/sia/settings/ark_sia_settings_settings.py +2 -0
- ark_sdk_python/models/services/sia/shortened_connection_string/__init__.py +11 -0
- ark_sdk_python/models/services/sia/shortened_connection_string/ark_sia_generate_shortened_connection_string.py +7 -0
- ark_sdk_python/models/services/sia/shortened_connection_string/ark_sia_generate_shortened_connection_string_response.py +8 -0
- ark_sdk_python/models/services/sia/sso/ark_sia_sso_get_short_lived_rdp_file.py +16 -2
- ark_sdk_python/models/services/sia/sso/ark_sia_sso_get_ssh_key.py +1 -0
- ark_sdk_python/models/services/sia/workspaces/db/__init__.py +27 -8
- ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_add_database.py +152 -0
- ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_database.py +101 -2
- ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_database_info.py +47 -1
- ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_databases_filter.py +13 -0
- ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_databases_stats.py +12 -0
- ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_delete_database.py +13 -0
- ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_get_database.py +13 -0
- ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_provider.py +25 -1
- ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_target_constraints.py +39 -0
- ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_update_database.py +166 -0
- ark_sdk_python/models/services/sm/ark_sm_session.py +1 -0
- ark_sdk_python/models/services/sm/ark_sm_sessions_filter.py +7 -0
- ark_sdk_python/models/services/uap/common/__init__.py +2 -0
- ark_sdk_python/models/services/uap/common/ark_uap_access_approval.py +24 -0
- ark_sdk_python/models/services/uap/common/ark_uap_change_info.py +1 -1
- ark_sdk_python/models/services/uap/common/ark_uap_conditions.py +15 -1
- ark_sdk_python/models/services/uap/common/ark_uap_metadata.py +1 -1
- ark_sdk_python/models/services/uap/common/ark_uap_principal.py +2 -2
- ark_sdk_python/models/services/uap/sia/common/ark_uap_sia_common_conditions.py +14 -0
- ark_sdk_python/models/services/uap/sia/vm/__init__.py +2 -0
- ark_sdk_python/models/services/uap/sia/vm/ark_uap_sia_vm_access_policy.py +11 -0
- ark_sdk_python/models/services/uap/sia/vm/ark_uap_sia_vm_conditions.py +28 -0
- ark_sdk_python/services/access/__init__.py +3 -0
- ark_sdk_python/services/access/ark_access_api.py +17 -0
- ark_sdk_python/services/access/assets/__init__.py +3 -0
- ark_sdk_python/services/access/assets/ark_access_assets_service.py +91 -0
- ark_sdk_python/services/ars/__init__.py +0 -0
- ark_sdk_python/services/ars/requests/__init__.py +7 -0
- ark_sdk_python/services/ars/requests/ark_ars_requests_async_request.py +145 -0
- ark_sdk_python/services/ars/requests/ark_ars_requests_service.py +377 -0
- ark_sdk_python/services/cmgr/__init__.py +2 -1
- ark_sdk_python/services/cmgr/ark_cmgr_component_operation_async_request.py +139 -0
- ark_sdk_python/services/cmgr/ark_cmgr_service.py +754 -3
- ark_sdk_python/services/identity/ark_identity_api.py +12 -0
- ark_sdk_python/services/identity/common/ark_identity_base_service.py +1 -1
- ark_sdk_python/services/identity/directories/ark_identity_directories_service.py +4 -1
- ark_sdk_python/services/identity/federations/__init__.py +3 -0
- ark_sdk_python/services/identity/federations/ark_identity_federations_service.py +218 -0
- ark_sdk_python/services/identity/users/ark_identity_users_service.py +76 -1
- ark_sdk_python/services/pcloud/ark_pcloud_api.py +12 -0
- ark_sdk_python/services/pcloud/common/ark_pcloud_base_service.py +1 -1
- ark_sdk_python/services/pcloud/usermgmt/__init__.py +3 -0
- ark_sdk_python/services/pcloud/usermgmt/ark_pcloud_usermgmt_service.py +513 -0
- ark_sdk_python/services/sia/access/__init__.py +2 -1
- ark_sdk_python/services/sia/access/ark_sia_access_service.py +693 -17
- ark_sdk_python/services/sia/access/ark_sia_upgrade_connector_async_request.py +136 -0
- ark_sdk_python/services/sia/ark_sia_api.py +12 -0
- ark_sdk_python/services/sia/k8s/ark_sia_k8s_service.py +77 -1
- ark_sdk_python/services/sia/secrets/db/ark_sia_db_secrets_service.py +354 -1
- ark_sdk_python/services/sia/settings/ark_sia_settings_service.py +60 -2
- ark_sdk_python/services/sia/shortened_connection_string/__init__.py +7 -0
- ark_sdk_python/services/sia/shortened_connection_string/ark_sia_shortened_connection_string_service.py +75 -0
- ark_sdk_python/services/sia/sso/ark_sia_sso_service.py +12 -2
- ark_sdk_python/services/sia/sso/consts.py +9 -0
- ark_sdk_python/services/sia/workspaces/db/ark_sia_db_workspace_service.py +346 -7
- ark_sdk_python/services/sm/ark_sm_service.py +4 -1
- ark_sdk_python/services/uap/common/ark_uap_base_service.py +13 -6
- ark_sdk_python/services/uap/sca/ark_uap_sca_service.py +4 -4
- {ark_sdk_python-2.1.4.dist-info → ark_sdk_python-2.2.0.dist-info}/METADATA +6 -1
- {ark_sdk_python-2.1.4.dist-info → ark_sdk_python-2.2.0.dist-info}/RECORD +212 -84
- {ark_sdk_python-2.1.4.dist-info → ark_sdk_python-2.2.0.dist-info}/WHEEL +1 -1
- {ark_sdk_python-2.1.4.dist-info → ark_sdk_python-2.2.0.dist-info}/licenses/NOTICES.md +371 -0
- {ark_sdk_python-2.1.4.dist-info → ark_sdk_python-2.2.0.dist-info}/entry_points.txt +0 -0
- {ark_sdk_python-2.1.4.dist-info → ark_sdk_python-2.2.0.dist-info}/licenses/LICENSE.txt +0 -0
|
@@ -0,0 +1,1342 @@
|
|
|
1
|
+
"""
|
|
2
|
+
One-time license consumption analytics (SCA / SIA ZSP / Privilege Cloud) for a single tenant.
|
|
3
|
+
|
|
4
|
+
Flow:
|
|
5
|
+
1. Collect every user in the Identity directory (suspended users included) - users that are not there are dropped
|
|
6
|
+
2. PrivCloud usage - members (recursively through nested roles) of the PrivCloud *human* roles, or with --pcloud-by-user-roles
|
|
7
|
+
the other way around: the roles of every Identity user (UserMgmt/GetUsersRolesAndAdministrativeRights), which also covers
|
|
8
|
+
users that are only in those roles through a group that can't be expanded from the role
|
|
9
|
+
3. SIA ZSP / SCA usage - sessions from session monitoring in the given timeframe
|
|
10
|
+
4. Split the users into exactly one of 7 groups according to what they used
|
|
11
|
+
(--active-only first leaves out users whose account is Locked / Disabled / Expired, listing them separately)
|
|
12
|
+
|
|
13
|
+
Output: stdout + JSON + Excel.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
python license_consumption_analytics.py --username user@cyberark.cloud.1234 --secret ... [--service-user] --output-dir ./out
|
|
17
|
+
(the secret can also come from the ARK_SECRET env variable)
|
|
18
|
+
(only counts are reported, add --include-usernames to also save and print the usernames)
|
|
19
|
+
(timeframe defaults to the last 365 days up to now, override with --start / --end, UTC ISO)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import queue
|
|
27
|
+
import random
|
|
28
|
+
import re
|
|
29
|
+
import time
|
|
30
|
+
from collections import Counter
|
|
31
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from datetime import datetime, timedelta, timezone
|
|
34
|
+
from typing import Callable, Dict, Final, Iterable, List, Optional, Set, TypeVar
|
|
35
|
+
|
|
36
|
+
import requests
|
|
37
|
+
from requests.adapters import HTTPAdapter
|
|
38
|
+
|
|
39
|
+
from ark_sdk_python.auth import ArkISPAuth
|
|
40
|
+
from ark_sdk_python.common.ark_logger import get_logger
|
|
41
|
+
from ark_sdk_python.models.ark_exceptions import ArkServiceException
|
|
42
|
+
from ark_sdk_python.models.auth import (
|
|
43
|
+
ArkAuthMethod,
|
|
44
|
+
ArkAuthProfile,
|
|
45
|
+
ArkSecret,
|
|
46
|
+
IdentityArkAuthMethodSettings,
|
|
47
|
+
IdentityServiceUserArkAuthMethodSettings,
|
|
48
|
+
)
|
|
49
|
+
from ark_sdk_python.models.common import ArkAccessMethod, ArkApplicationCode
|
|
50
|
+
from ark_sdk_python.models.common.identity import DirectoryService
|
|
51
|
+
from ark_sdk_python.models.services.identity.directories import (
|
|
52
|
+
ArkIdentityEntityType,
|
|
53
|
+
ArkIdentityListDirectories,
|
|
54
|
+
ArkIdentityListDirectoriesEntities,
|
|
55
|
+
)
|
|
56
|
+
from ark_sdk_python.models.services.identity.roles import ArkIdentityListRoleMembers, ArkIdentityRoleIdByName
|
|
57
|
+
from ark_sdk_python.models.services.identity.users import ArkIdentityGetUserState, ArkIdentityListUserRoles
|
|
58
|
+
from ark_sdk_python.models.services.pcloud.usermgmt import ArkPCloudVaultUsersFilter
|
|
59
|
+
from ark_sdk_python.models.services.sm import ArkSMSessionsFilter, ArkSMSessionStatus
|
|
60
|
+
from ark_sdk_python.services.identity.directories import ArkIdentityDirectoriesService
|
|
61
|
+
from ark_sdk_python.services.identity.federations import ArkIdentityFederationsService
|
|
62
|
+
from ark_sdk_python.services.identity.roles import ArkIdentityRolesService
|
|
63
|
+
from ark_sdk_python.services.identity.users import ArkIdentityUsersService
|
|
64
|
+
from ark_sdk_python.services.pcloud.usermgmt import ArkPCloudUserMgmtService
|
|
65
|
+
from ark_sdk_python.services.sm import ArkSMService
|
|
66
|
+
|
|
67
|
+
LOGGER_NAME: Final[str] = 'license-consumption'
|
|
68
|
+
logger = logging.getLogger(LOGGER_NAME) # configured in main through get_logger, which returns this same logger
|
|
69
|
+
|
|
70
|
+
# Human roles only. Service user type / system permission roles and group roles are intentionally not counted.
|
|
71
|
+
PCLOUD_HUMAN_ROLES: Final[List[str]] = [
|
|
72
|
+
f'Privilege Cloud {kind}{suffix}'
|
|
73
|
+
for suffix in ('', ' Lite', ' Basic')
|
|
74
|
+
for kind in ('Administrators', 'Auditors', 'Safe Managers', 'Users')
|
|
75
|
+
] + ['Privilege Cloud External Vendors']
|
|
76
|
+
|
|
77
|
+
# Session monitoring classification
|
|
78
|
+
SCA_APPLICATION_CODES: Final[Set[ArkApplicationCode]] = {ArkApplicationCode.SCA}
|
|
79
|
+
SIA_APPLICATION_CODES: Final[Set[ArkApplicationCode]] = {ArkApplicationCode.SIA, ArkApplicationCode.DPA}
|
|
80
|
+
ZSP_ACCESS_METHODS: Final[Set[ArkAccessMethod]] = {ArkAccessMethod.JIT} # any other access method is not ZSP
|
|
81
|
+
|
|
82
|
+
DEFAULT_LOOKBACK_DAYS: Final[int] = 365
|
|
83
|
+
ENTITIES_LIMIT: Final[int] = 1_000_000
|
|
84
|
+
DEFAULT_MAX_WORKERS: Final[int] = 128
|
|
85
|
+
DEFAULT_SESSIONS_WORKERS: Final[int] = 8
|
|
86
|
+
DEFAULT_SESSIONS_PAGE_SIZE: Final[int] = 1000
|
|
87
|
+
DEFAULT_VAULT_PAGE_SIZE: Final[int] = 200
|
|
88
|
+
GROUP_SEARCH_LIMIT: Final[int] = 1000
|
|
89
|
+
UNKNOWN_DIRECTORY: Final[str] = 'Unknown'
|
|
90
|
+
|
|
91
|
+
SCA, ZSP, PCLOUD = 'SCA', 'SIA ZSP', 'PrivCloud'
|
|
92
|
+
GROUPS: Final[Dict[str, frozenset]] = {
|
|
93
|
+
'SCA + SIA ZSP + PrivCloud': frozenset({SCA, ZSP, PCLOUD}),
|
|
94
|
+
'SCA + SIA ZSP': frozenset({SCA, ZSP}),
|
|
95
|
+
'SIA ZSP + PrivCloud': frozenset({ZSP, PCLOUD}),
|
|
96
|
+
'SCA + PrivCloud': frozenset({SCA, PCLOUD}),
|
|
97
|
+
'PrivCloud only': frozenset({PCLOUD}),
|
|
98
|
+
'SIA ZSP only': frozenset({ZSP}),
|
|
99
|
+
'SCA only': frozenset({SCA}),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class Usage:
|
|
105
|
+
sca: Set[str] = field(default_factory=set)
|
|
106
|
+
zsp: Set[str] = field(default_factory=set)
|
|
107
|
+
pcloud: Set[str] = field(default_factory=set)
|
|
108
|
+
pcloud_groups: List[Dict[str, str]] = field(default_factory=list) # groups found as members of the PrivCloud roles
|
|
109
|
+
excluded_inactive: Dict[str, List[Dict[str, str]]] = field(
|
|
110
|
+
default_factory=dict
|
|
111
|
+
) # product -> inactive users left out with --active-only
|
|
112
|
+
not_in_vault: List[str] = field(default_factory=list) # PrivCloud role holders that were never provisioned to the vault
|
|
113
|
+
role_lookup_failed: List[str] = field(default_factory=list) # users whose roles couldn't be read
|
|
114
|
+
state_lookup_failed: List[str] = field(default_factory=list) # users whose account state couldn't be read
|
|
115
|
+
notes: List[str] = field(default_factory=list)
|
|
116
|
+
stats: Dict[str, int] = field(default_factory=dict)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _norm(name: Optional[str]) -> Optional[str]:
|
|
120
|
+
return name.strip().lower() if name and name.strip() else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _parse_time(value: str) -> datetime:
|
|
124
|
+
return datetime.fromisoformat(value.replace('Z', '+00:00')).astimezone(timezone.utc)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _fmt_time(value: datetime) -> str:
|
|
128
|
+
return value.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _day_windows(start: datetime, end: datetime) -> Iterable[tuple]:
|
|
132
|
+
"""
|
|
133
|
+
Splits the timeframe to UTC day windows. The sessions API pages by offset, which gets slower the deeper it goes, so many small
|
|
134
|
+
windows are faster than a few big ones, and they can be fetched in parallel.
|
|
135
|
+
"""
|
|
136
|
+
cursor = start
|
|
137
|
+
while cursor < end:
|
|
138
|
+
nxt = datetime(cursor.year, cursor.month, cursor.day, tzinfo=timezone.utc) + timedelta(days=1)
|
|
139
|
+
yield cursor, min(nxt, end)
|
|
140
|
+
cursor = nxt
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def collect_identity_users(isp_auth: ArkISPAuth, user_ids: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
144
|
+
"""
|
|
145
|
+
Returns {normalized username: display username} of every user in the Identity directory (suspended included).
|
|
146
|
+
When `user_ids` is given, it is filled with {normalized username: user id}
|
|
147
|
+
"""
|
|
148
|
+
service = ArkIdentityDirectoriesService(isp_auth)
|
|
149
|
+
users: Dict[str, str] = {}
|
|
150
|
+
logger.info('Listing identity directory users')
|
|
151
|
+
for page in service.list_directories_entities(
|
|
152
|
+
ArkIdentityListDirectoriesEntities(
|
|
153
|
+
directories=[DirectoryService.Identity],
|
|
154
|
+
entity_types=[ArkIdentityEntityType.User],
|
|
155
|
+
page_size=ENTITIES_LIMIT,
|
|
156
|
+
limit=ENTITIES_LIMIT,
|
|
157
|
+
)
|
|
158
|
+
):
|
|
159
|
+
for entity in page.items:
|
|
160
|
+
if key := _norm(entity.name):
|
|
161
|
+
users[key] = entity.name
|
|
162
|
+
if user_ids is not None:
|
|
163
|
+
user_ids[key] = entity.id
|
|
164
|
+
logger.info(f'Found [{len(users)}] identity users')
|
|
165
|
+
if len(users) >= ENTITIES_LIMIT:
|
|
166
|
+
logger.warning(f'Identity users reached the query limit [{ENTITIES_LIMIT}], the list may be truncated')
|
|
167
|
+
return users
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def collect_directories(isp_auth: ArkISPAuth) -> List[Dict[str, str]]:
|
|
171
|
+
"""Lists the directories of the tenant (Cloud Directory, Active Directory, federated), with their type, name and id"""
|
|
172
|
+
logger.info('Listing the identity directories')
|
|
173
|
+
try:
|
|
174
|
+
directories = ArkIdentityDirectoriesService(isp_auth).list_directories(ArkIdentityListDirectories())
|
|
175
|
+
except Exception as ex: # pylint: disable=broad-except
|
|
176
|
+
logger.warning(f'Failed listing the directories: {ex}')
|
|
177
|
+
return []
|
|
178
|
+
result = [
|
|
179
|
+
{
|
|
180
|
+
'type': directory.directory.value,
|
|
181
|
+
'name': directory.display_name or '',
|
|
182
|
+
'config': directory.config or '',
|
|
183
|
+
'directory_service_uuid': directory.directory_service_uuid,
|
|
184
|
+
}
|
|
185
|
+
for directory in directories
|
|
186
|
+
]
|
|
187
|
+
logger.info(f'Found [{len(result)}] directories: {[(d["type"], d["name"] or d["config"]) for d in result]}')
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def collect_federations(isp_auth: ArkISPAuth) -> List[Dict[str, str]]:
|
|
192
|
+
"""Lists the external identity providers (SAML 2.0 / OIDC federations) of the tenant"""
|
|
193
|
+
logger.info('Listing the identity federations')
|
|
194
|
+
try:
|
|
195
|
+
federations = ArkIdentityFederationsService(isp_auth).list_federations()
|
|
196
|
+
except Exception as ex: # pylint: disable=broad-except
|
|
197
|
+
logger.warning(f'Failed listing the federations: {ex}')
|
|
198
|
+
return []
|
|
199
|
+
result = [
|
|
200
|
+
{
|
|
201
|
+
'federation_id': f.federation_id,
|
|
202
|
+
'name': f.federation_name,
|
|
203
|
+
'type': f.federation_type,
|
|
204
|
+
'active': 'yes' if f.active else 'no',
|
|
205
|
+
'domains': ', '.join(f.domains),
|
|
206
|
+
}
|
|
207
|
+
for f in federations
|
|
208
|
+
]
|
|
209
|
+
logger.info(f'Found [{len(result)}] federations: {[(f["name"], f["type"], f["active"]) for f in result]}')
|
|
210
|
+
return result
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _resolve_group_directory(
|
|
214
|
+
directories_service: ArkIdentityDirectoriesService, group_id: str, group_name: str, cache: Dict[str, Dict[str, str]]
|
|
215
|
+
) -> Dict[str, str]:
|
|
216
|
+
"""Directory type (CDS / AdProxy / FDS) and directory name of a group member, by searching the directories for the group"""
|
|
217
|
+
if group_id in cache:
|
|
218
|
+
return cache[group_id]
|
|
219
|
+
found = {'directory_type': UNKNOWN_DIRECTORY, 'directory_name': UNKNOWN_DIRECTORY}
|
|
220
|
+
try:
|
|
221
|
+
candidates = [
|
|
222
|
+
entity
|
|
223
|
+
for page in directories_service.list_directories_entities(
|
|
224
|
+
ArkIdentityListDirectoriesEntities(
|
|
225
|
+
entity_types=[ArkIdentityEntityType.Group], search=group_name, page_size=GROUP_SEARCH_LIMIT, limit=GROUP_SEARCH_LIMIT
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
for entity in page.items
|
|
229
|
+
]
|
|
230
|
+
# An id match is exact, a name match is the fallback when the role member guid differs from the directory entity id
|
|
231
|
+
match = next((e for e in candidates if e.id == group_id), None) or next(
|
|
232
|
+
(e for e in candidates if _norm(e.name) == _norm(group_name) or _norm(e.display_name) == _norm(group_name)), None
|
|
233
|
+
)
|
|
234
|
+
if match:
|
|
235
|
+
found = {'directory_type': match.directory_service_type.value, 'directory_name': match.service_instance_localized}
|
|
236
|
+
else:
|
|
237
|
+
logger.warning(f'Group [{group_name}] [{group_id}] was not found in any directory, its directory type is unknown')
|
|
238
|
+
except Exception as ex: # the directory is informative only, it must not stop the run
|
|
239
|
+
logger.warning(f'Failed resolving the directory of group [{group_name}] [{group_id}]: {ex}')
|
|
240
|
+
cache[group_id] = found
|
|
241
|
+
return found
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def collect_pcloud_users(isp_auth: ArkISPAuth, usage: Usage) -> None:
|
|
245
|
+
"""
|
|
246
|
+
Members of the PrivCloud human roles, expanding nested roles.
|
|
247
|
+
Group members can't be expanded with the current SDK, they are recorded in `usage.pcloud_groups` with their directory type
|
|
248
|
+
"""
|
|
249
|
+
roles_service = ArkIdentityRolesService(isp_auth)
|
|
250
|
+
directories_service = ArkIdentityDirectoriesService(isp_auth)
|
|
251
|
+
visited: Set[str] = set()
|
|
252
|
+
seen_groups: Set[tuple] = set()
|
|
253
|
+
directory_cache: Dict[str, Dict[str, str]] = {}
|
|
254
|
+
|
|
255
|
+
def _walk(human_role: str, role_name: Optional[str] = None, role_id: Optional[str] = None, nested_in: str = '') -> None:
|
|
256
|
+
members = roles_service.list_role_members(ArkIdentityListRoleMembers(role_name=role_name, role_id=role_id))
|
|
257
|
+
logger.debug(f'Role [{role_name or role_id}] has [{len(members)}] direct members')
|
|
258
|
+
for member in members:
|
|
259
|
+
if member.member_type == ArkIdentityEntityType.User:
|
|
260
|
+
if key := _norm(member.member_name):
|
|
261
|
+
usage.pcloud.add(key)
|
|
262
|
+
elif member.member_type == ArkIdentityEntityType.Role:
|
|
263
|
+
if member.member_id not in visited:
|
|
264
|
+
visited.add(member.member_id)
|
|
265
|
+
logger.debug(f'Expanding nested role [{member.member_name}]')
|
|
266
|
+
_walk(human_role, role_id=member.member_id, nested_in=member.member_name)
|
|
267
|
+
elif (human_role, member.member_id) not in seen_groups:
|
|
268
|
+
seen_groups.add((human_role, member.member_id))
|
|
269
|
+
directory = _resolve_group_directory(directories_service, member.member_id, member.member_name, directory_cache)
|
|
270
|
+
logger.info(
|
|
271
|
+
f'Role [{human_role}] has group member [{member.member_name}] of directory type [{directory["directory_type"]}] '
|
|
272
|
+
f'([{directory["directory_name"]}]), not expanded'
|
|
273
|
+
)
|
|
274
|
+
usage.pcloud_groups.append(
|
|
275
|
+
{
|
|
276
|
+
'role': human_role,
|
|
277
|
+
'nested_role': nested_in,
|
|
278
|
+
'group_name': member.member_name,
|
|
279
|
+
'group_id': member.member_id,
|
|
280
|
+
**directory,
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
for role_name in PCLOUD_HUMAN_ROLES:
|
|
285
|
+
before = len(usage.pcloud)
|
|
286
|
+
try:
|
|
287
|
+
_walk(role_name, role_name=role_name)
|
|
288
|
+
logger.info(f'Role [{role_name}] done, [{len(usage.pcloud) - before}] new unique users (total [{len(usage.pcloud)}])')
|
|
289
|
+
except Exception as ex: # a role that doesn't exist on the tenant must not stop the run
|
|
290
|
+
logger.warning(f'Role [{role_name}] skipped: {ex}')
|
|
291
|
+
usage.notes.append(f'Role [{role_name}] skipped: {ex}')
|
|
292
|
+
if usage.pcloud_groups:
|
|
293
|
+
by_directory: Dict[str, int] = {}
|
|
294
|
+
for entry in usage.pcloud_groups:
|
|
295
|
+
by_directory[entry['directory_type']] = by_directory.get(entry['directory_type'], 0) + 1
|
|
296
|
+
logger.warning(
|
|
297
|
+
f'[{len(usage.pcloud_groups)}] group member(s) in PrivCloud roles were not expanded, by directory type: {by_directory}'
|
|
298
|
+
)
|
|
299
|
+
usage.notes.append(
|
|
300
|
+
f'{len(usage.pcloud_groups)} group member(s) in PrivCloud roles were NOT expanded (no SDK API) - PrivCloud may be '
|
|
301
|
+
f'under-counted, by directory type: {by_directory} (see pcloud_role_groups)'
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
T = TypeVar('T')
|
|
306
|
+
LOOKUP_MAX_ATTEMPTS: Final[int] = 4
|
|
307
|
+
LOOKUP_MAX_DELAY_SECONDS: Final[int] = 16
|
|
308
|
+
RETRYABLE_STATUS_CODES: Final[Set[int]] = {429, 500, 502, 503, 504}
|
|
309
|
+
STATUS_IN_ERROR: Final[re.Pattern] = re.compile(r'\[(\d{3})\]\s*$')
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _is_transient(ex: Exception) -> bool:
|
|
313
|
+
"""Rate limiting (429), server errors (5xx), timeouts and connection errors are worth another attempt, anything else is final"""
|
|
314
|
+
if isinstance(ex, (requests.ConnectionError, requests.Timeout)):
|
|
315
|
+
return True
|
|
316
|
+
if isinstance(ex, ArkServiceException):
|
|
317
|
+
match = STATUS_IN_ERROR.search(str(ex.error)) # the service errors end with the http status, e.g. "... [body] - [429]"
|
|
318
|
+
return bool(match) and int(match.group(1)) in RETRYABLE_STATUS_CODES
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def call_with_retries(func: Callable[[], T], what: str) -> T:
|
|
323
|
+
"""Runs the per user call, retrying transient failures with an exponential backoff and jitter"""
|
|
324
|
+
for attempt in range(1, LOOKUP_MAX_ATTEMPTS + 1):
|
|
325
|
+
try:
|
|
326
|
+
return func()
|
|
327
|
+
except Exception as ex: # pylint: disable=broad-except
|
|
328
|
+
if attempt == LOOKUP_MAX_ATTEMPTS or not _is_transient(ex):
|
|
329
|
+
raise
|
|
330
|
+
delay = min(LOOKUP_MAX_DELAY_SECONDS, 2**attempt) * random.uniform(0.5, 1.0)
|
|
331
|
+
logger.warning(
|
|
332
|
+
f'{what} failed with a transient error, retrying in [{delay:.1f}] seconds (attempt [{attempt}/{LOOKUP_MAX_ATTEMPTS}]): {ex}'
|
|
333
|
+
)
|
|
334
|
+
time.sleep(delay)
|
|
335
|
+
raise AssertionError('unreachable') # pragma: no cover
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def widen_connection_pool(service: ArkIdentityUsersService, size: int) -> None:
|
|
339
|
+
"""
|
|
340
|
+
The SDK client keeps the default pool of 10 connections per host, so with more parallel workers the extra connections are
|
|
341
|
+
discarded after each request ("Connection pool is full") and every call pays a new TLS handshake. Sizes the pool to the workers.
|
|
342
|
+
"""
|
|
343
|
+
adapter = HTTPAdapter(pool_connections=size, pool_maxsize=size)
|
|
344
|
+
service._client.session.mount('https://', adapter) # pylint: disable=protected-access
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def collect_pcloud_users_by_user_roles(isp_auth: ArkISPAuth, usage: Usage, user_ids: Dict[str, str], max_workers: int) -> None:
|
|
348
|
+
"""
|
|
349
|
+
PrivCloud users resolved from the users side: the roles of each Identity user, taken from
|
|
350
|
+
UserMgmt/GetUsersRolesAndAdministrativeRights, are checked against the PrivCloud human roles.
|
|
351
|
+
Unlike the role members, this also catches users that hold a role only through a group.
|
|
352
|
+
"""
|
|
353
|
+
roles_service = ArkIdentityRolesService(isp_auth)
|
|
354
|
+
users_service = ArkIdentityUsersService(isp_auth)
|
|
355
|
+
widen_connection_pool(users_service, max_workers)
|
|
356
|
+
pcloud_roles: Set[str] = {_norm(name) for name in PCLOUD_HUMAN_ROLES}
|
|
357
|
+
for name in PCLOUD_HUMAN_ROLES:
|
|
358
|
+
try: # a role missing on the tenant is fine, matching by name still works
|
|
359
|
+
pcloud_roles.add(_norm(roles_service.role_id_by_name(ArkIdentityRoleIdByName(role_name=name))))
|
|
360
|
+
except Exception as ex: # pylint: disable=broad-except
|
|
361
|
+
logger.debug(f'Role [{name}] has no id on the tenant: {ex}')
|
|
362
|
+
|
|
363
|
+
def _is_pcloud_user(item: tuple) -> tuple:
|
|
364
|
+
username, user_id = item
|
|
365
|
+
roles = call_with_retries(
|
|
366
|
+
lambda: users_service.list_user_roles(ArkIdentityListUserRoles(user_id=user_id)), f'Reading the roles of user id [{user_id}]'
|
|
367
|
+
)
|
|
368
|
+
return username, any(_norm(value) in pcloud_roles for role in roles for value in (role.role_id, role.name, role.role_name))
|
|
369
|
+
|
|
370
|
+
logger.info(f'Resolving the roles of [{len(user_ids)}] identity users with [{max_workers}] workers')
|
|
371
|
+
failed: List[str] = []
|
|
372
|
+
done = 0
|
|
373
|
+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
374
|
+
futures = {pool.submit(_is_pcloud_user, item): item[0] for item in user_ids.items()}
|
|
375
|
+
for future, username in futures.items():
|
|
376
|
+
try:
|
|
377
|
+
_, is_pcloud = future.result()
|
|
378
|
+
if is_pcloud:
|
|
379
|
+
usage.pcloud.add(username)
|
|
380
|
+
except Exception as ex: # pylint: disable=broad-except
|
|
381
|
+
failed.append(username)
|
|
382
|
+
logger.warning(f'Failed resolving the roles of user id [{user_ids[username]}]: {ex}')
|
|
383
|
+
done += 1
|
|
384
|
+
if done % 500 == 0:
|
|
385
|
+
logger.info(f'Resolved the roles of [{done}/{len(futures)}] users, [{len(usage.pcloud)}] PrivCloud users so far')
|
|
386
|
+
usage.stats['pcloud_role_lookup_failed_users'] = len(failed)
|
|
387
|
+
if failed:
|
|
388
|
+
logger.warning(f'The roles of [{len(failed)}] users could not be resolved, PrivCloud may be under-counted')
|
|
389
|
+
usage.role_lookup_failed = [identity_name for identity_name in sorted(failed)]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def list_vault_usernames(service: ArkPCloudUserMgmtService, page_size: int) -> Set[str]:
|
|
393
|
+
"""
|
|
394
|
+
The normalized usernames of all the vault users, read in small pages (Get Users pageOffset / pageSize), without the extended
|
|
395
|
+
details, since one big call can time out. A page that fails with a transient error is read again from the same offset.
|
|
396
|
+
"""
|
|
397
|
+
names: Set[str] = set()
|
|
398
|
+
offset = 0
|
|
399
|
+
failures = 0
|
|
400
|
+
while True:
|
|
401
|
+
try:
|
|
402
|
+
for page in service.list_vault_users_pages(
|
|
403
|
+
ArkPCloudVaultUsersFilter(page_size=page_size, page_offset=offset, extended_details=False)
|
|
404
|
+
):
|
|
405
|
+
names.update(_norm(user.username) for user in page.items)
|
|
406
|
+
offset += len(page.items)
|
|
407
|
+
failures = 0
|
|
408
|
+
logger.info(f'Read [{offset}] vault users so far')
|
|
409
|
+
names.discard(None)
|
|
410
|
+
return names
|
|
411
|
+
except Exception as ex: # pylint: disable=broad-except
|
|
412
|
+
failures += 1
|
|
413
|
+
if failures >= LOOKUP_MAX_ATTEMPTS or not _is_transient(ex):
|
|
414
|
+
raise
|
|
415
|
+
delay = min(LOOKUP_MAX_DELAY_SECONDS, 2**failures) * random.uniform(0.5, 1.0)
|
|
416
|
+
logger.warning(
|
|
417
|
+
f'Listing the vault users from [{offset}] failed with a transient error, retrying in [{delay:.1f}] seconds: {ex}'
|
|
418
|
+
)
|
|
419
|
+
time.sleep(delay)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def validate_pcloud_in_vault(
|
|
423
|
+
isp_auth: ArkISPAuth, usage: Usage, identity_users: Dict[str, str], page_size: int = DEFAULT_VAULT_PAGE_SIZE
|
|
424
|
+
) -> None:
|
|
425
|
+
"""
|
|
426
|
+
Keeps among the PrivCloud users only those that exist in the vault. A user is provisioned to the vault automatically the first
|
|
427
|
+
time it actually goes to PrivCloud, so a user that holds a PrivCloud role but never did is not a consumer. The vault users are
|
|
428
|
+
listed once in pages (PCloud Get Users), not one call per user.
|
|
429
|
+
"""
|
|
430
|
+
vault_names = list_vault_usernames(ArkPCloudUserMgmtService(isp_auth), page_size)
|
|
431
|
+
if not vault_names:
|
|
432
|
+
# a failed read must not look like "nobody was ever provisioned", which would empty the PrivCloud count
|
|
433
|
+
raise RuntimeError('The vault returned no users, check that the user is allowed to list the vault users (Audit / Add-Update users)')
|
|
434
|
+
missing = sorted((usage.pcloud & identity_users.keys()) - vault_names) # the users not in Identity are dropped on their own
|
|
435
|
+
usage.pcloud.difference_update(missing)
|
|
436
|
+
usage.not_in_vault = missing
|
|
437
|
+
usage.stats['vault_users_listed'] = len(vault_names)
|
|
438
|
+
logger.info(f'The vault holds [{len(vault_names)}] users, [{len(missing)}] PrivCloud role holders are not in it and were excluded')
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def exclude_inactive_users(
|
|
442
|
+
isp_auth: ArkISPAuth, usage: Usage, identity_users: Dict[str, str], user_ids: Dict[str, str], max_workers: int
|
|
443
|
+
) -> None:
|
|
444
|
+
"""
|
|
445
|
+
Removes from the usage the users whose account is not active, i.e. Locked / Disabled / Expired (UserMgmt/GetUserAttributes).
|
|
446
|
+
Only the users that used something are checked. A user whose state can't be read is kept, and noted.
|
|
447
|
+
"""
|
|
448
|
+
candidates = sorted((usage.sca | usage.zsp | usage.pcloud) & identity_users.keys())
|
|
449
|
+
users_service = ArkIdentityUsersService(isp_auth)
|
|
450
|
+
widen_connection_pool(users_service, max_workers)
|
|
451
|
+
logger.info(f'Checking the account state of [{len(candidates)}] users that used a product with [{max_workers}] workers')
|
|
452
|
+
inactive: Dict[str, str] = {}
|
|
453
|
+
unknown: List[str] = []
|
|
454
|
+
|
|
455
|
+
def _state(username: str) -> tuple:
|
|
456
|
+
user_id = user_ids[username]
|
|
457
|
+
return username, call_with_retries(
|
|
458
|
+
lambda: users_service.user_state(ArkIdentityGetUserState(user_id=user_id)), f'Reading the state of user id [{user_id}]'
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
462
|
+
futures = {pool.submit(_state, username): username for username in candidates}
|
|
463
|
+
for future, username in futures.items():
|
|
464
|
+
try:
|
|
465
|
+
_, user_state = future.result()
|
|
466
|
+
if not user_state.is_active:
|
|
467
|
+
inactive[username] = user_state.state
|
|
468
|
+
except Exception as ex: # pylint: disable=broad-except
|
|
469
|
+
unknown.append(username)
|
|
470
|
+
logger.warning(f'Failed reading the state of user id [{user_ids[username]}], keeping it: {ex}')
|
|
471
|
+
for product, users in ((SCA, usage.sca), (ZSP, usage.zsp), (PCLOUD, usage.pcloud)):
|
|
472
|
+
usage.excluded_inactive[product] = [{'username': identity_users[u], 'state': inactive[u]} for u in sorted(users & inactive.keys())]
|
|
473
|
+
users.difference_update(inactive) # in place, `-=` with dict keys would only rebind the local
|
|
474
|
+
logger.info(f'Excluded [{len(inactive)}] inactive users: {dict(Counter(inactive.values()))}')
|
|
475
|
+
usage.stats['inactive_users_excluded'] = len(inactive)
|
|
476
|
+
if unknown:
|
|
477
|
+
usage.stats['user_state_lookup_failed_users'] = len(unknown)
|
|
478
|
+
usage.state_lookup_failed = sorted(unknown)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _fetch_window_sessions(sm: ArkSMService, win_start: datetime, win_end: datetime, page_size: Optional[int]) -> tuple:
|
|
482
|
+
"""
|
|
483
|
+
All the sessions that started in the window, as ([(session id, application code, access method, user, failed)], largest page
|
|
484
|
+
that the service returned)
|
|
485
|
+
"""
|
|
486
|
+
search = f'startTime ge {_fmt_time(win_start)} AND startTime le {_fmt_time(win_end)}'
|
|
487
|
+
rows: List[tuple] = []
|
|
488
|
+
largest_page = 0
|
|
489
|
+
for page in sm.list_sessions_by(ArkSMSessionsFilter(search=search, limit=page_size)):
|
|
490
|
+
largest_page = max(largest_page, len(page.items))
|
|
491
|
+
rows.extend(
|
|
492
|
+
(s.session_id, s.application_code, s.access_method, _norm(s.user), s.session_status == ArkSMSessionStatus.FAILED)
|
|
493
|
+
for s in page.items
|
|
494
|
+
)
|
|
495
|
+
return rows, largest_page
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _usable_page_size(sm: ArkSMService, end: datetime, page_size: Optional[int]) -> Optional[int]:
|
|
499
|
+
"""The requested page size when the sessions API accepts it (tried once on the last day), else None for the service default"""
|
|
500
|
+
if not page_size:
|
|
501
|
+
return None
|
|
502
|
+
window_start = end - timedelta(days=1)
|
|
503
|
+
search = f'startTime ge {_fmt_time(window_start)} AND startTime le {_fmt_time(end)}'
|
|
504
|
+
try:
|
|
505
|
+
next(iter(sm.list_sessions_by(ArkSMSessionsFilter(search=search, limit=page_size))), None)
|
|
506
|
+
return page_size
|
|
507
|
+
except Exception as ex: # pylint: disable=broad-except
|
|
508
|
+
logger.warning(f'The sessions API did not accept the page size [{page_size}], using its default page size: {ex}')
|
|
509
|
+
return None
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def collect_session_users(
|
|
513
|
+
isp_auth: ArkISPAuth,
|
|
514
|
+
start: datetime,
|
|
515
|
+
end: datetime,
|
|
516
|
+
usage: Usage,
|
|
517
|
+
workers: int = DEFAULT_SESSIONS_WORKERS,
|
|
518
|
+
page_size: Optional[int] = DEFAULT_SESSIONS_PAGE_SIZE,
|
|
519
|
+
) -> None:
|
|
520
|
+
"""
|
|
521
|
+
Users with at least one non-failed SCA / SIA ZSP session in the timeframe, by session monitoring.
|
|
522
|
+
The timeframe is split to day windows that are fetched in parallel, each worker with its own client. A window that fails with
|
|
523
|
+
a transient error is fetched again from its start (the sessions are deduplicated by id, so that is safe).
|
|
524
|
+
"""
|
|
525
|
+
windows = list(_day_windows(start, end))
|
|
526
|
+
workers = max(1, min(workers, len(windows)))
|
|
527
|
+
services: 'queue.Queue[ArkSMService]' = queue.Queue()
|
|
528
|
+
for _ in range(workers):
|
|
529
|
+
services.put(ArkSMService(isp_auth))
|
|
530
|
+
page_size = _usable_page_size(services.queue[0], end, page_size)
|
|
531
|
+
|
|
532
|
+
def _fetch(window: tuple) -> tuple:
|
|
533
|
+
sm = services.get()
|
|
534
|
+
try:
|
|
535
|
+
return call_with_retries(
|
|
536
|
+
lambda: _fetch_window_sessions(sm, *window, page_size), f'Fetching the sessions of [{_fmt_time(window[0])}]'
|
|
537
|
+
)
|
|
538
|
+
finally:
|
|
539
|
+
services.put(sm)
|
|
540
|
+
|
|
541
|
+
seen: Set[str] = set()
|
|
542
|
+
breakdown: Dict[str, int] = {}
|
|
543
|
+
largest_page = 0
|
|
544
|
+
logger.info(
|
|
545
|
+
f'Fetching the sessions of [{len(windows)}] day windows with [{workers}] workers, requested page size [{page_size or "default"}]'
|
|
546
|
+
)
|
|
547
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
548
|
+
futures = {pool.submit(_fetch, window): window for window in windows}
|
|
549
|
+
for done, future in enumerate(as_completed(futures), start=1):
|
|
550
|
+
rows, window_largest_page = future.result()
|
|
551
|
+
largest_page = max(largest_page, window_largest_page)
|
|
552
|
+
for session_id, application_code, access_method, user, failed in rows:
|
|
553
|
+
if session_id in seen:
|
|
554
|
+
continue
|
|
555
|
+
seen.add(session_id)
|
|
556
|
+
is_sca = application_code in SCA_APPLICATION_CODES
|
|
557
|
+
is_zsp = application_code in SIA_APPLICATION_CODES and access_method in ZSP_ACCESS_METHODS
|
|
558
|
+
if is_sca or is_zsp: # other sessions (SIA vaulted / unknown) are not counted and are not shown
|
|
559
|
+
label = f'{application_code}/{access_method if access_method else None}'
|
|
560
|
+
breakdown[label] = breakdown.get(label, 0) + 1
|
|
561
|
+
if not user or failed:
|
|
562
|
+
continue
|
|
563
|
+
if is_sca:
|
|
564
|
+
usage.sca.add(user)
|
|
565
|
+
elif is_zsp:
|
|
566
|
+
usage.zsp.add(user)
|
|
567
|
+
if done % 10 == 0 or done == len(windows):
|
|
568
|
+
logger.info(
|
|
569
|
+
f'Fetched [{done}/{len(windows)}] day windows, totals so far: sessions [{len(seen)}], SCA users [{len(usage.sca)}], '
|
|
570
|
+
f'SIA ZSP users [{len(usage.zsp)}]'
|
|
571
|
+
)
|
|
572
|
+
usage.stats['sessions_total'] = len(seen)
|
|
573
|
+
logger.info(f'The largest sessions page returned by the service held [{largest_page}] sessions')
|
|
574
|
+
logger.info(f'Sessions by applicationCode/accessMethod: {json.dumps(breakdown, sort_keys=True)}')
|
|
575
|
+
usage.notes.append(f'Sessions by applicationCode/accessMethod: {json.dumps(breakdown, sort_keys=True)}')
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def compute_groups(usage: Usage, identity_users: Dict[str, str]) -> Dict[str, List[str]]:
|
|
579
|
+
"""Every identity user who used at least one product lands in exactly one group. Users missing from Identity are dropped"""
|
|
580
|
+
members: Dict[str, Set[str]] = {}
|
|
581
|
+
for product, users in ((SCA, usage.sca), (ZSP, usage.zsp), (PCLOUD, usage.pcloud)):
|
|
582
|
+
for user in users & identity_users.keys():
|
|
583
|
+
members.setdefault(user, set()).add(product)
|
|
584
|
+
result: Dict[str, List[str]] = {name: [] for name in GROUPS}
|
|
585
|
+
for user, products in members.items():
|
|
586
|
+
for name, expected in GROUPS.items():
|
|
587
|
+
if frozenset(products) == expected:
|
|
588
|
+
result[name].append(identity_users[user])
|
|
589
|
+
for product, users in ((SCA, usage.sca), (ZSP, usage.zsp), (PCLOUD, usage.pcloud)):
|
|
590
|
+
if dropped := len(users - identity_users.keys()):
|
|
591
|
+
logger.info(f'[{dropped}] {product} users are not in Identity anymore and were dropped')
|
|
592
|
+
for name, users in result.items():
|
|
593
|
+
logger.info(f'Group [{name}]: [{len(users)}] users')
|
|
594
|
+
return {name: sorted(users, key=str.lower) for name, users in result.items()}
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
STAT_EXPLANATIONS: Final[Dict[str, str]] = {
|
|
598
|
+
'sessions_total': 'Distinct sessions that session monitoring returned for the timeframe, before filtering failed ones and by product.',
|
|
599
|
+
'vault_users_listed': 'Users that the vault returned (PCloud Get Users), the base of the PrivCloud vault validation.',
|
|
600
|
+
'inactive_users_excluded': 'Distinct users that used a product but were left out because the account is Locked, Disabled or Expired.',
|
|
601
|
+
'user_state_lookup_failed_users': 'Users whose account state could not be read, they were kept as active.',
|
|
602
|
+
'pcloud_role_lookup_failed_users': 'Users whose roles could not be read, so PrivCloud may be under-counted.',
|
|
603
|
+
}
|
|
604
|
+
PRODUCT_SOURCES: Final[Dict[str, str]] = {SCA: 'Session monitoring', ZSP: 'Session monitoring', PCLOUD: 'Role membership'}
|
|
605
|
+
VAULT_VALIDATED_SOURCE: Final[str] = 'Role membership + vault validation'
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _product_descriptions(pcloud_by_user_roles: bool, validate_pcloud_vault: bool = False) -> Dict[str, str]:
|
|
609
|
+
pcloud_source = (
|
|
610
|
+
'the roles of every Identity user (UserMgmt/GetUsersRolesAndAdministrativeRights), which also covers roles held through groups'
|
|
611
|
+
if pcloud_by_user_roles
|
|
612
|
+
else 'the members of the roles, expanding nested roles. Groups inside a role are not expanded, so users that hold a role only '
|
|
613
|
+
'through a group are missed (use --pcloud-by-user-roles to count them)'
|
|
614
|
+
)
|
|
615
|
+
return {
|
|
616
|
+
SCA: 'Secure Cloud Access. A user is counted when it has at least one non-failed SCA session in the timeframe.',
|
|
617
|
+
ZSP: 'SIA Zero Standing Privileges. A user is counted when it has at least one non-failed SIA / DPA session in the timeframe that '
|
|
618
|
+
'used just-in-time (JIT) access.',
|
|
619
|
+
PCLOUD: 'Privilege Cloud. A user is counted when it holds one of the PrivCloud human roles (Administrators, Auditors, Safe Managers, '
|
|
620
|
+
f'Users, their Lite and Basic variants, External Vendors). It is a role membership, not a connection, resolved from {pcloud_source}.'
|
|
621
|
+
+ (
|
|
622
|
+
' With the vault validation the user must also exist in the vault (PCloud user management, matched by username, case insensitive), '
|
|
623
|
+
'which happens when the user first goes to PrivCloud, so users that only hold a role are not counted.'
|
|
624
|
+
if validate_pcloud_vault
|
|
625
|
+
else ' The users are not checked against the vault (the check was turned off with --no-validate-pcloud-vault).'
|
|
626
|
+
),
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _product_source(product: str, options: Dict[str, bool]) -> str:
|
|
631
|
+
"""The 'Counted from' text of a product, PrivCloud also mentions the vault validation when it was done"""
|
|
632
|
+
return VAULT_VALIDATED_SOURCE if product == PCLOUD and options['validate_pcloud_vault'] else PRODUCT_SOURCES[product]
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _describe_group(products: frozenset) -> str:
|
|
636
|
+
ordered = [p for p in (SCA, ZSP, PCLOUD) if p in products]
|
|
637
|
+
others = [p for p in (SCA, ZSP, PCLOUD) if p not in products]
|
|
638
|
+
if len(ordered) == 3:
|
|
639
|
+
return 'Users that used all three products: SCA, SIA ZSP and PrivCloud.'
|
|
640
|
+
if len(ordered) == 2:
|
|
641
|
+
return f'Users that used {ordered[0]} and {ordered[1]}, but not {others[0]}.'
|
|
642
|
+
return f'Users that used only {ordered[0]}, neither {others[0]} nor {others[1]}.'
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _methodology(report_options: Dict[str, bool]) -> List[str]:
|
|
646
|
+
lines = [
|
|
647
|
+
'Scope: the users of one tenant. Only users that exist in the Identity directory (suspended users included) are counted. '
|
|
648
|
+
'Usernames that show up in sessions or roles but are not in Identity are not counted and are dropped.',
|
|
649
|
+
'Usage: SCA and SIA ZSP are counted from session monitoring, a user used the product when it connected at least once in the '
|
|
650
|
+
'timeframe. Failed sessions are ignored. PrivCloud is counted from the membership of the PrivCloud human roles.',
|
|
651
|
+
'Groups: every user that used at least one product is placed in exactly one of the 7 groups, by the exact combination of products '
|
|
652
|
+
'it used. The groups are mutually exclusive, so their counts add up to the total unique users.',
|
|
653
|
+
'Usernames are compared case-insensitively.',
|
|
654
|
+
]
|
|
655
|
+
if report_options['active_only']:
|
|
656
|
+
lines.append(
|
|
657
|
+
'Active users only: users whose account state is Locked, Disabled or Expired were taken out of every product before the '
|
|
658
|
+
'groups were computed, and are reported separately.'
|
|
659
|
+
)
|
|
660
|
+
else:
|
|
661
|
+
lines.append(
|
|
662
|
+
'Account state is not checked, users that are Locked, Disabled or Expired are counted (use --active-only to leave them out).'
|
|
663
|
+
)
|
|
664
|
+
if report_options['validate_pcloud_vault']:
|
|
665
|
+
lines.append(
|
|
666
|
+
'Vault validation: a PrivCloud user is counted only when it also exists in the vault (PCloud user management). A user is '
|
|
667
|
+
'provisioned to the vault automatically the first time it actually goes to PrivCloud, so users that only hold a role are left out.'
|
|
668
|
+
)
|
|
669
|
+
else:
|
|
670
|
+
lines.append(
|
|
671
|
+
'The PrivCloud users are not checked against the vault, so users that hold a role but never went to PrivCloud are counted '
|
|
672
|
+
'(the vault validation was turned off with --no-validate-pcloud-vault, it is on by default).'
|
|
673
|
+
)
|
|
674
|
+
if report_options['identity_only']:
|
|
675
|
+
lines.append(
|
|
676
|
+
'Identity only mode: session monitoring was skipped, so only PrivCloud and the Identity data are reported, without groups.'
|
|
677
|
+
)
|
|
678
|
+
if not report_options['include_usernames']:
|
|
679
|
+
lines.append('Usernames are not included in this report (use --include-usernames to add them), only counts.')
|
|
680
|
+
return lines
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _users_entry(users: List[str], include_usernames: bool) -> dict:
|
|
684
|
+
entry: dict = {'count': len(users)}
|
|
685
|
+
if include_usernames:
|
|
686
|
+
entry['users'] = users
|
|
687
|
+
return entry
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def build_report(
|
|
691
|
+
usage: Usage,
|
|
692
|
+
identity_users: Dict[str, str],
|
|
693
|
+
groups: Optional[Dict[str, List[str]]],
|
|
694
|
+
start: Optional[str],
|
|
695
|
+
end: Optional[str],
|
|
696
|
+
identity_only: bool = False,
|
|
697
|
+
active_only: bool = False,
|
|
698
|
+
directories: Optional[List[Dict[str, str]]] = None,
|
|
699
|
+
federations: Optional[List[Dict[str, str]]] = None,
|
|
700
|
+
include_usernames: bool = False,
|
|
701
|
+
pcloud_by_user_roles: bool = False,
|
|
702
|
+
validate_pcloud_vault: bool = False,
|
|
703
|
+
) -> dict:
|
|
704
|
+
options = {
|
|
705
|
+
'identity_only': identity_only,
|
|
706
|
+
'active_only': active_only,
|
|
707
|
+
'pcloud_by_user_roles': pcloud_by_user_roles,
|
|
708
|
+
'validate_pcloud_vault': validate_pcloud_vault,
|
|
709
|
+
'include_usernames': include_usernames,
|
|
710
|
+
}
|
|
711
|
+
dropped = {PCLOUD: sorted(usage.pcloud - identity_users.keys())}
|
|
712
|
+
per_product = {PCLOUD: len(usage.pcloud & identity_users.keys())}
|
|
713
|
+
if not identity_only:
|
|
714
|
+
dropped[SCA] = sorted(usage.sca - identity_users.keys())
|
|
715
|
+
dropped[ZSP] = sorted(usage.zsp - identity_users.keys())
|
|
716
|
+
per_product = {
|
|
717
|
+
SCA: len(usage.sca & identity_users.keys()),
|
|
718
|
+
ZSP: len(usage.zsp & identity_users.keys()),
|
|
719
|
+
PCLOUD: per_product[PCLOUD],
|
|
720
|
+
}
|
|
721
|
+
notes = list(usage.notes)
|
|
722
|
+
for label, names in (
|
|
723
|
+
('The roles of {n} users could not be resolved, PrivCloud may be under-counted', usage.role_lookup_failed),
|
|
724
|
+
('The state of {n} users could not be read, they were kept as active', usage.state_lookup_failed),
|
|
725
|
+
):
|
|
726
|
+
if names:
|
|
727
|
+
notes.append(label.format(n=len(names)) + (f': {sorted(names)}' if include_usernames else ''))
|
|
728
|
+
|
|
729
|
+
report: dict = {
|
|
730
|
+
'mode': 'identity_only' if identity_only else 'full',
|
|
731
|
+
'generated_at': _fmt_time(datetime.now(timezone.utc)),
|
|
732
|
+
'options': options,
|
|
733
|
+
'explanations': {
|
|
734
|
+
'methodology': _methodology(options),
|
|
735
|
+
'products': {
|
|
736
|
+
name: text
|
|
737
|
+
for name, text in _product_descriptions(pcloud_by_user_roles, validate_pcloud_vault).items()
|
|
738
|
+
if name in per_product
|
|
739
|
+
},
|
|
740
|
+
'groups': {name: _describe_group(products) for name, products in GROUPS.items()} if not identity_only else {},
|
|
741
|
+
},
|
|
742
|
+
'directories': directories or [],
|
|
743
|
+
'federations': federations or [],
|
|
744
|
+
'identity_users_count': len(identity_users),
|
|
745
|
+
'per_product_unique_users': per_product,
|
|
746
|
+
'stats': usage.stats,
|
|
747
|
+
'notes': notes,
|
|
748
|
+
'dropped_not_in_identity': {product: _users_entry(users, include_usernames) for product, users in dropped.items()},
|
|
749
|
+
}
|
|
750
|
+
if not pcloud_by_user_roles:
|
|
751
|
+
# resolved through the user roles the groups are irrelevant, the users are found whatever the way they hold the role
|
|
752
|
+
report['pcloud_role_groups'] = usage.pcloud_groups
|
|
753
|
+
if identity_only:
|
|
754
|
+
report['pcloud_users'] = _users_entry(
|
|
755
|
+
sorted((identity_users[u] for u in usage.pcloud & identity_users.keys()), key=str.lower), include_usernames
|
|
756
|
+
)
|
|
757
|
+
else:
|
|
758
|
+
report['timeframe'] = {'start': start, 'end': end}
|
|
759
|
+
report['groups'] = {
|
|
760
|
+
name: {
|
|
761
|
+
'description': _describe_group(GROUPS[name]),
|
|
762
|
+
'products': [p for p in (SCA, ZSP, PCLOUD) if p in GROUPS[name]],
|
|
763
|
+
**_users_entry(users, include_usernames),
|
|
764
|
+
}
|
|
765
|
+
for name, users in (groups or {}).items()
|
|
766
|
+
}
|
|
767
|
+
report['total_unique_users'] = sum(len(u) for u in (groups or {}).values())
|
|
768
|
+
if validate_pcloud_vault:
|
|
769
|
+
report['excluded_not_in_vault'] = _users_entry(
|
|
770
|
+
sorted((identity_users[u] for u in usage.not_in_vault), key=str.lower), include_usernames
|
|
771
|
+
)
|
|
772
|
+
if active_only:
|
|
773
|
+
report['excluded_inactive_users'] = {}
|
|
774
|
+
for product, entries in usage.excluded_inactive.items():
|
|
775
|
+
by_state: Dict[str, int] = {}
|
|
776
|
+
for entry in entries:
|
|
777
|
+
by_state[entry['state']] = by_state.get(entry['state'], 0) + 1
|
|
778
|
+
excluded: dict = {'count': len(entries), 'by_state': by_state}
|
|
779
|
+
if include_usernames:
|
|
780
|
+
excluded['users'] = entries
|
|
781
|
+
report['excluded_inactive_users'][product] = excluded
|
|
782
|
+
return report
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _share(count: int, total: int) -> float:
|
|
786
|
+
return count / total if total else 0.0
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def write_excel(report: dict, path: str) -> None:
|
|
790
|
+
try:
|
|
791
|
+
from openpyxl import Workbook # pylint: disable=import-outside-toplevel
|
|
792
|
+
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side # pylint: disable=import-outside-toplevel
|
|
793
|
+
from openpyxl.utils import get_column_letter # pylint: disable=import-outside-toplevel
|
|
794
|
+
except ImportError as ex:
|
|
795
|
+
raise RuntimeError('Excel output requires openpyxl, run: pip install openpyxl') from ex
|
|
796
|
+
|
|
797
|
+
header_fill = PatternFill('solid', fgColor='1F3864')
|
|
798
|
+
band_fill = PatternFill('solid', fgColor='EEF3FA')
|
|
799
|
+
total_fill = PatternFill('solid', fgColor='D9E2F3')
|
|
800
|
+
border = Border(*(Side(style='thin', color='BFBFBF') for _ in range(4)))
|
|
801
|
+
wrap_top = Alignment(wrap_text=True, vertical='top')
|
|
802
|
+
|
|
803
|
+
def _title(sheet, title: str, description: Optional[str], width: int) -> int:
|
|
804
|
+
sheet['A1'] = title
|
|
805
|
+
sheet['A1'].font = Font(size=16, bold=True, color='1F3864')
|
|
806
|
+
sheet.row_dimensions[1].height = 26
|
|
807
|
+
next_row = 2
|
|
808
|
+
if description:
|
|
809
|
+
sheet.merge_cells(start_row=2, start_column=1, end_row=2, end_column=width)
|
|
810
|
+
sheet['A2'] = description
|
|
811
|
+
sheet['A2'].font = Font(italic=True, color='595959')
|
|
812
|
+
sheet['A2'].alignment = wrap_top
|
|
813
|
+
sheet.row_dimensions[2].height = 15 * max(1, len(description) // 110 + 1)
|
|
814
|
+
next_row = 3
|
|
815
|
+
return next_row + 1
|
|
816
|
+
|
|
817
|
+
def _table(
|
|
818
|
+
sheet, row: int, headers: List[str], rows: List[list], total_row: bool = False, percent_cols: tuple = (), wrap_cols: tuple = ()
|
|
819
|
+
) -> int:
|
|
820
|
+
for col, header in enumerate(headers, 1):
|
|
821
|
+
cell = sheet.cell(row=row, column=col, value=header)
|
|
822
|
+
cell.font = Font(bold=True, color='FFFFFF')
|
|
823
|
+
cell.fill = header_fill
|
|
824
|
+
cell.border = border
|
|
825
|
+
cell.alignment = Alignment(vertical='center', wrap_text=True)
|
|
826
|
+
for index, values in enumerate(rows):
|
|
827
|
+
is_total = total_row and index == len(rows) - 1
|
|
828
|
+
for col, value in enumerate(values, 1):
|
|
829
|
+
cell = sheet.cell(row=row + 1 + index, column=col, value=value)
|
|
830
|
+
cell.border = border
|
|
831
|
+
cell.alignment = wrap_top if col in wrap_cols else Alignment(vertical='top')
|
|
832
|
+
if col in percent_cols:
|
|
833
|
+
cell.number_format = '0.0%'
|
|
834
|
+
if is_total:
|
|
835
|
+
cell.font = Font(bold=True)
|
|
836
|
+
cell.fill = total_fill
|
|
837
|
+
elif index % 2:
|
|
838
|
+
cell.fill = band_fill
|
|
839
|
+
return row + len(rows) + 2
|
|
840
|
+
|
|
841
|
+
def _widths(sheet, widths: List[int]) -> None:
|
|
842
|
+
for col, width in enumerate(widths, 1):
|
|
843
|
+
sheet.column_dimensions[get_column_letter(col)].width = width
|
|
844
|
+
|
|
845
|
+
def _table_sheet(name: str, color: str, title: str, description: str, headers: List[str], rows: List[list], widths: List[int]) -> None:
|
|
846
|
+
sheet = wb.create_sheet(name[:31])
|
|
847
|
+
sheet.sheet_properties.tabColor = color
|
|
848
|
+
header_row = _title(sheet, title, description, len(headers))
|
|
849
|
+
end_row = _table(sheet, header_row, headers, rows) - 2
|
|
850
|
+
_widths(sheet, widths)
|
|
851
|
+
sheet.freeze_panes = sheet.cell(row=header_row + 1, column=1)
|
|
852
|
+
if rows:
|
|
853
|
+
sheet.auto_filter.ref = f'A{header_row}:{get_column_letter(len(headers))}{end_row}'
|
|
854
|
+
|
|
855
|
+
wb = Workbook()
|
|
856
|
+
summary = wb.active
|
|
857
|
+
summary.title = 'Summary'
|
|
858
|
+
summary.sheet_properties.tabColor = '1F3864'
|
|
859
|
+
options = report['options']
|
|
860
|
+
identity_total = report['identity_users_count']
|
|
861
|
+
subtitle = f"Generated {report['generated_at']}"
|
|
862
|
+
if 'timeframe' in report:
|
|
863
|
+
subtitle = f"Timeframe {report['timeframe']['start']} - {report['timeframe']['end']}. {subtitle}"
|
|
864
|
+
row = _title(summary, 'License consumption report', subtitle, 5)
|
|
865
|
+
if 'groups' in report:
|
|
866
|
+
summary.cell(row=row - 1, column=1, value='Consumption by group').font = Font(bold=True, size=12)
|
|
867
|
+
total = report['total_unique_users']
|
|
868
|
+
group_rows = [
|
|
869
|
+
[name, ' + '.join(data['products']), data['count'], _share(data['count'], total), data['description']]
|
|
870
|
+
for name, data in report['groups'].items()
|
|
871
|
+
]
|
|
872
|
+
group_rows.append(
|
|
873
|
+
['Total unique users', '', total, 1.0 if total else 0.0, 'Users that used at least one product, each counted once.']
|
|
874
|
+
)
|
|
875
|
+
row = _table(summary, row, ['Group', 'Products used', 'Users', 'Share of total', 'What it means'], group_rows, True, (4,), (5,))
|
|
876
|
+
summary.cell(row=row, column=1, value='Per product').font = Font(bold=True, size=12)
|
|
877
|
+
product_rows = [
|
|
878
|
+
[product, _product_source(product, options), count, _share(count, identity_total), report['explanations']['products'][product]]
|
|
879
|
+
for product, count in report['per_product_unique_users'].items()
|
|
880
|
+
]
|
|
881
|
+
row = _table(
|
|
882
|
+
summary,
|
|
883
|
+
row + 1,
|
|
884
|
+
['Product', 'Counted from', 'Unique users', 'Share of Identity users', 'How it is counted'],
|
|
885
|
+
product_rows,
|
|
886
|
+
False,
|
|
887
|
+
(4,),
|
|
888
|
+
(5,),
|
|
889
|
+
)
|
|
890
|
+
if report['notes']:
|
|
891
|
+
summary.cell(row=row, column=1, value='Notes').font = Font(bold=True, size=12)
|
|
892
|
+
for offset, note in enumerate(report['notes'], 1):
|
|
893
|
+
summary.merge_cells(start_row=row + offset, start_column=1, end_row=row + offset, end_column=5)
|
|
894
|
+
cell = summary.cell(row=row + offset, column=1, value=note)
|
|
895
|
+
cell.alignment = wrap_top
|
|
896
|
+
summary.row_dimensions[row + offset].height = 15 * max(1, len(note) // 150 + 1)
|
|
897
|
+
_widths(summary, [34, 30, 14, 18, 95])
|
|
898
|
+
|
|
899
|
+
status = {True: 'yes', False: 'no'}
|
|
900
|
+
info_rows = [
|
|
901
|
+
[
|
|
902
|
+
'Mode',
|
|
903
|
+
'identity only' if options['identity_only'] else 'full',
|
|
904
|
+
'Identity only skips session monitoring, so SCA, SIA ZSP and the groups are not calculated.',
|
|
905
|
+
],
|
|
906
|
+
[
|
|
907
|
+
'Identity users',
|
|
908
|
+
identity_total,
|
|
909
|
+
'Every user in the Identity directory, suspended users included. This is the base for the shares.',
|
|
910
|
+
],
|
|
911
|
+
[
|
|
912
|
+
'Active users only',
|
|
913
|
+
status[options['active_only']],
|
|
914
|
+
'When yes, Locked / Disabled / Expired users are taken out of the counts and listed below.',
|
|
915
|
+
],
|
|
916
|
+
[
|
|
917
|
+
'PrivCloud resolved from user roles',
|
|
918
|
+
status[options['pcloud_by_user_roles']],
|
|
919
|
+
'When yes, the roles of each user are read instead of the members of each role, which also counts users that hold a role through a group.',
|
|
920
|
+
],
|
|
921
|
+
[
|
|
922
|
+
'Validate PrivCloud in the vault',
|
|
923
|
+
status[options['validate_pcloud_vault']],
|
|
924
|
+
'When yes, a PrivCloud user is counted only when it exists in the vault, users are provisioned there when they first go to PrivCloud.',
|
|
925
|
+
],
|
|
926
|
+
['Usernames included', status[options['include_usernames']], 'When no, the report holds only counts, no usernames.'],
|
|
927
|
+
]
|
|
928
|
+
if 'excluded_not_in_vault' in report:
|
|
929
|
+
info_rows.append(
|
|
930
|
+
[
|
|
931
|
+
'Not in vault - PrivCloud',
|
|
932
|
+
report['excluded_not_in_vault']['count'],
|
|
933
|
+
'Users that hold a PrivCloud role but were never provisioned to the vault, so they are not counted.',
|
|
934
|
+
]
|
|
935
|
+
)
|
|
936
|
+
for product, data in report.get('excluded_inactive_users', {}).items():
|
|
937
|
+
states = ', '.join(f'{state}: {count}' for state, count in data['by_state'].items()) or '-'
|
|
938
|
+
info_rows.append(
|
|
939
|
+
[
|
|
940
|
+
f'Excluded inactive - {product}',
|
|
941
|
+
data['count'],
|
|
942
|
+
f'Users with a Locked / Disabled / Expired account that used {product} and were left out ({states}).',
|
|
943
|
+
]
|
|
944
|
+
)
|
|
945
|
+
for product, data in report['dropped_not_in_identity'].items():
|
|
946
|
+
info_rows.append(
|
|
947
|
+
[
|
|
948
|
+
f'Not in Identity - {product}',
|
|
949
|
+
data['count'],
|
|
950
|
+
f'Usernames that used {product} but are not Identity users any more. They are not counted.',
|
|
951
|
+
]
|
|
952
|
+
)
|
|
953
|
+
for key, value in report['stats'].items():
|
|
954
|
+
info_rows.append([key.replace('_', ' ').capitalize(), value, STAT_EXPLANATIONS.get(key, '')])
|
|
955
|
+
_table_sheet(
|
|
956
|
+
'Run info',
|
|
957
|
+
'2E75B6',
|
|
958
|
+
'Run information',
|
|
959
|
+
'The options of this run and the numbers behind the totals.',
|
|
960
|
+
['Item', 'Value', 'Explanation'],
|
|
961
|
+
info_rows,
|
|
962
|
+
[40, 18, 110],
|
|
963
|
+
)
|
|
964
|
+
wb['Run info'].column_dimensions['C'].width = 110
|
|
965
|
+
for cells in wb['Run info'].iter_rows(min_row=4, min_col=3, max_col=3):
|
|
966
|
+
for cell in cells:
|
|
967
|
+
cell.alignment = wrap_top
|
|
968
|
+
|
|
969
|
+
how = wb.create_sheet('How it works')
|
|
970
|
+
how.sheet_properties.tabColor = '2E75B6'
|
|
971
|
+
first = _title(how, 'How the numbers are calculated', None, 2)
|
|
972
|
+
for number, line in enumerate(report['explanations']['methodology'], 1):
|
|
973
|
+
how.cell(row=first + number - 1, column=1, value=number).alignment = Alignment(vertical='top', horizontal='center')
|
|
974
|
+
cell = how.cell(row=first + number - 1, column=2, value=line)
|
|
975
|
+
cell.alignment = wrap_top
|
|
976
|
+
how.row_dimensions[first + number - 1].height = 15 * max(1, len(line) // 120 + 1)
|
|
977
|
+
_widths(how, [6, 130])
|
|
978
|
+
|
|
979
|
+
if options['include_usernames']:
|
|
980
|
+
if 'groups' in report:
|
|
981
|
+
rows = [
|
|
982
|
+
[user, name] + ['yes' if p in GROUPS[name] else '' for p in (SCA, ZSP, PCLOUD)]
|
|
983
|
+
for name, data in report['groups'].items()
|
|
984
|
+
for user in data['users']
|
|
985
|
+
]
|
|
986
|
+
_table_sheet(
|
|
987
|
+
'Users',
|
|
988
|
+
'70AD47',
|
|
989
|
+
'Users by group',
|
|
990
|
+
'Every counted user with the group it landed in and the products it used.',
|
|
991
|
+
['Username', 'Group', SCA, ZSP, PCLOUD],
|
|
992
|
+
rows,
|
|
993
|
+
[48, 30, 10, 10, 12],
|
|
994
|
+
)
|
|
995
|
+
else:
|
|
996
|
+
rows = [[user, 'yes'] for user in report['pcloud_users']['users']]
|
|
997
|
+
_table_sheet(
|
|
998
|
+
'Users',
|
|
999
|
+
'70AD47',
|
|
1000
|
+
'PrivCloud users',
|
|
1001
|
+
'Identity users that hold a PrivCloud human role.',
|
|
1002
|
+
['Username', PCLOUD],
|
|
1003
|
+
rows,
|
|
1004
|
+
[48, 12],
|
|
1005
|
+
)
|
|
1006
|
+
_table_sheet(
|
|
1007
|
+
'Directories',
|
|
1008
|
+
'A5A5A5',
|
|
1009
|
+
'Identity directories',
|
|
1010
|
+
'The directories of the tenant, a user can come from any of them.',
|
|
1011
|
+
['Type', 'Name', 'Config', 'Directory service id'],
|
|
1012
|
+
[[d['type'], d['name'], d['config'], d['directory_service_uuid']] for d in report['directories']],
|
|
1013
|
+
[14, 34, 34, 42],
|
|
1014
|
+
)
|
|
1015
|
+
_table_sheet(
|
|
1016
|
+
'Federations',
|
|
1017
|
+
'A5A5A5',
|
|
1018
|
+
'External identity providers',
|
|
1019
|
+
'The SAML 2.0 / OIDC federations of the tenant, users can sign in through them.',
|
|
1020
|
+
['Name', 'Type', 'Active', 'Domains', 'Federation id'],
|
|
1021
|
+
[[f['name'], f['type'], f['active'], f['domains'], f['federation_id']] for f in report['federations']],
|
|
1022
|
+
[34, 12, 10, 44, 40],
|
|
1023
|
+
)
|
|
1024
|
+
if 'pcloud_role_groups' in report:
|
|
1025
|
+
_table_sheet(
|
|
1026
|
+
'PrivCloud role groups',
|
|
1027
|
+
'FFC000',
|
|
1028
|
+
'Groups inside PrivCloud roles',
|
|
1029
|
+
'Groups that are members of the PrivCloud roles and could not be expanded to users, so the users that hold a role only through them are not counted '
|
|
1030
|
+
'unless the run used --pcloud-by-user-roles.',
|
|
1031
|
+
['Role', 'Nested role', 'Group', 'Group id', 'Directory type', 'Directory'],
|
|
1032
|
+
[
|
|
1033
|
+
[e['role'], e['nested_role'], e['group_name'], e['group_id'], e['directory_type'], e['directory_name']]
|
|
1034
|
+
for e in report['pcloud_role_groups']
|
|
1035
|
+
],
|
|
1036
|
+
[34, 30, 34, 40, 16, 30],
|
|
1037
|
+
)
|
|
1038
|
+
if options['include_usernames']:
|
|
1039
|
+
if 'excluded_not_in_vault' in report:
|
|
1040
|
+
_table_sheet(
|
|
1041
|
+
'Not in vault',
|
|
1042
|
+
'C00000',
|
|
1043
|
+
'PrivCloud role holders not in the vault',
|
|
1044
|
+
'Users that hold a PrivCloud role but were never provisioned to the vault (they never went to PrivCloud), so they were not counted.',
|
|
1045
|
+
['Username'],
|
|
1046
|
+
[[user] for user in report['excluded_not_in_vault']['users']],
|
|
1047
|
+
[48],
|
|
1048
|
+
)
|
|
1049
|
+
if 'excluded_inactive_users' in report:
|
|
1050
|
+
_table_sheet(
|
|
1051
|
+
'Excluded (inactive)',
|
|
1052
|
+
'C00000',
|
|
1053
|
+
'Excluded inactive users',
|
|
1054
|
+
'Users that used a product but whose account is Locked, Disabled or Expired, so they were not counted.',
|
|
1055
|
+
['Product', 'Username', 'State'],
|
|
1056
|
+
[
|
|
1057
|
+
[product, user['username'], user['state']]
|
|
1058
|
+
for product, data in report['excluded_inactive_users'].items()
|
|
1059
|
+
for user in data['users']
|
|
1060
|
+
],
|
|
1061
|
+
[14, 48, 14],
|
|
1062
|
+
)
|
|
1063
|
+
_table_sheet(
|
|
1064
|
+
'Not in Identity',
|
|
1065
|
+
'C00000',
|
|
1066
|
+
'Users not in Identity',
|
|
1067
|
+
'Usernames that used a product but are not Identity users, they are not counted.',
|
|
1068
|
+
['Product', 'Username'],
|
|
1069
|
+
[[product, user] for product, data in report['dropped_not_in_identity'].items() for user in data['users']],
|
|
1070
|
+
[14, 48],
|
|
1071
|
+
)
|
|
1072
|
+
wb.save(path)
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def print_report(report: dict) -> None:
|
|
1076
|
+
options = report['options']
|
|
1077
|
+
line = '=' * 100
|
|
1078
|
+
print(f'\n{line}\nLICENSE CONSUMPTION REPORT ({report["generated_at"]})\n{line}')
|
|
1079
|
+
if 'timeframe' in report:
|
|
1080
|
+
print(f"Timeframe: {report['timeframe']['start']} - {report['timeframe']['end']}")
|
|
1081
|
+
print(
|
|
1082
|
+
f"Mode: {report['mode']} active only: {options['active_only']} vault validation: {options['validate_pcloud_vault']} "
|
|
1083
|
+
f"usernames included: {options['include_usernames']}"
|
|
1084
|
+
)
|
|
1085
|
+
print('\nHow the numbers are calculated:')
|
|
1086
|
+
for number, text in enumerate(report['explanations']['methodology'], 1):
|
|
1087
|
+
print(f' {number}. {text}')
|
|
1088
|
+
|
|
1089
|
+
print(f"\nIdentity users: {report['identity_users_count']} (every user of the directory, suspended included)")
|
|
1090
|
+
print('\nPer product unique users (Identity users only):')
|
|
1091
|
+
for product, count in report['per_product_unique_users'].items():
|
|
1092
|
+
print(f' {product}: {count}\n {report["explanations"]["products"][product]}')
|
|
1093
|
+
|
|
1094
|
+
if 'groups' in report:
|
|
1095
|
+
print('\nConsumption by group (each user is in exactly one group):')
|
|
1096
|
+
for name, data in report['groups'].items():
|
|
1097
|
+
print(f" {name}: {data['count']}\n {data['description']}")
|
|
1098
|
+
for user in data.get('users', []):
|
|
1099
|
+
print(f' - {user}')
|
|
1100
|
+
print(f"\nTotal unique users: {report['total_unique_users']} (users that used at least one product)")
|
|
1101
|
+
else:
|
|
1102
|
+
pcloud_users = report['pcloud_users']
|
|
1103
|
+
print(f"\nPrivCloud users: {pcloud_users['count']}")
|
|
1104
|
+
for user in pcloud_users.get('users', []):
|
|
1105
|
+
print(f' - {user}')
|
|
1106
|
+
|
|
1107
|
+
if 'excluded_not_in_vault' in report:
|
|
1108
|
+
not_in_vault = report['excluded_not_in_vault']
|
|
1109
|
+
print(f"\nPrivCloud role holders not in the vault: {not_in_vault['count']} (never provisioned to the vault, not counted)")
|
|
1110
|
+
for user in not_in_vault.get('users', []):
|
|
1111
|
+
print(f' - {user}')
|
|
1112
|
+
for product, data in report.get('excluded_inactive_users', {}).items():
|
|
1113
|
+
print(
|
|
1114
|
+
f"\nExcluded inactive users of {product}: {data['count']} {data['by_state'] or ''} (account Locked / Disabled / Expired, not counted)"
|
|
1115
|
+
)
|
|
1116
|
+
for user in data.get('users', []):
|
|
1117
|
+
print(f" - {user['username']} ({user['state']})")
|
|
1118
|
+
print('\nNot in Identity (not counted):')
|
|
1119
|
+
for product, data in report['dropped_not_in_identity'].items():
|
|
1120
|
+
print(f" {product}: {data['count']}")
|
|
1121
|
+
for user in data.get('users', []):
|
|
1122
|
+
print(f' - {user}')
|
|
1123
|
+
|
|
1124
|
+
if 'pcloud_role_groups' in report:
|
|
1125
|
+
print(f"\nGroups as PrivCloud role members (not expanded to users): {len(report['pcloud_role_groups'])}")
|
|
1126
|
+
for entry in report['pcloud_role_groups']:
|
|
1127
|
+
print(f" [{entry['role']}] {entry['group_name']} - {entry['directory_type']} ({entry['directory_name']})")
|
|
1128
|
+
print(f"\nFederations (external identity providers): {len(report['federations'])}")
|
|
1129
|
+
for federation in report['federations']:
|
|
1130
|
+
print(f" {federation['name']} - {federation['type']} - active: {federation['active']} - domains: {federation['domains']}")
|
|
1131
|
+
print(f"\nDirectories: {len(report['directories'])}")
|
|
1132
|
+
for directory in report['directories']:
|
|
1133
|
+
print(f" {directory['type']} - {directory['name'] or directory['config']} [{directory['directory_service_uuid']}]")
|
|
1134
|
+
for note in report['notes']:
|
|
1135
|
+
print(f'NOTE: {note}')
|
|
1136
|
+
print(line)
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
|
|
1140
|
+
"""Timeframe, output and logging arguments shared with the system user variant"""
|
|
1141
|
+
parser.add_argument('--start', help=f'Timeframe start, UTC ISO (default: {DEFAULT_LOOKBACK_DAYS} days before --end)')
|
|
1142
|
+
parser.add_argument('--end', help='Timeframe end, UTC ISO (default: now)')
|
|
1143
|
+
parser.add_argument('--output-dir', default='.', help='Where to write the json and xlsx files')
|
|
1144
|
+
parser.add_argument(
|
|
1145
|
+
'--pcloud-by-user-roles',
|
|
1146
|
+
action=argparse.BooleanOptionalAction,
|
|
1147
|
+
default=True,
|
|
1148
|
+
help='Resolve PrivCloud users from the roles of each Identity user instead of from the role members, '
|
|
1149
|
+
'to count users that are in the roles only through groups (default: on, use --no-pcloud-by-user-roles to turn off)',
|
|
1150
|
+
)
|
|
1151
|
+
parser.add_argument(
|
|
1152
|
+
'--max-workers', type=int, default=DEFAULT_MAX_WORKERS, help='Parallel per user lookups (--pcloud-by-user-roles, --active-only)'
|
|
1153
|
+
)
|
|
1154
|
+
parser.add_argument(
|
|
1155
|
+
'--validate-pcloud-vault',
|
|
1156
|
+
action=argparse.BooleanOptionalAction,
|
|
1157
|
+
default=True,
|
|
1158
|
+
help='Count a PrivCloud user only when it also exists in the vault (PCloud user management). Users are provisioned to the '
|
|
1159
|
+
'vault when they first go to PrivCloud, so this leaves out users that only hold a role. Needs the right to list vault users '
|
|
1160
|
+
'(default: on, use --no-validate-pcloud-vault to turn off)',
|
|
1161
|
+
)
|
|
1162
|
+
parser.add_argument(
|
|
1163
|
+
'--vault-page-size',
|
|
1164
|
+
type=int,
|
|
1165
|
+
default=DEFAULT_VAULT_PAGE_SIZE,
|
|
1166
|
+
help='Vault users requested per call with --validate-pcloud-vault, smaller pages avoid the timeouts of a big vault',
|
|
1167
|
+
)
|
|
1168
|
+
parser.add_argument(
|
|
1169
|
+
'--sessions-workers',
|
|
1170
|
+
type=int,
|
|
1171
|
+
default=DEFAULT_SESSIONS_WORKERS,
|
|
1172
|
+
help='Parallel session monitoring requests, the timeframe is fetched as day windows (SCA and SIA ZSP)',
|
|
1173
|
+
)
|
|
1174
|
+
parser.add_argument(
|
|
1175
|
+
'--sessions-page-size',
|
|
1176
|
+
type=int,
|
|
1177
|
+
default=DEFAULT_SESSIONS_PAGE_SIZE,
|
|
1178
|
+
help='Sessions requested per call to session monitoring, 0 uses the service default page size. '
|
|
1179
|
+
'A page size the API does not accept is dropped automatically',
|
|
1180
|
+
)
|
|
1181
|
+
parser.add_argument(
|
|
1182
|
+
'--include-usernames',
|
|
1183
|
+
action='store_true',
|
|
1184
|
+
help='Also save and print the usernames (per group, not in Identity, excluded inactive), without it only counts are reported',
|
|
1185
|
+
)
|
|
1186
|
+
parser.add_argument(
|
|
1187
|
+
'--active-only',
|
|
1188
|
+
action=argparse.BooleanOptionalAction,
|
|
1189
|
+
default=True,
|
|
1190
|
+
help='Count only users with an active account, users that are Locked / Disabled / Expired are left out and listed separately '
|
|
1191
|
+
'(default: on, use --no-active-only to turn off)',
|
|
1192
|
+
)
|
|
1193
|
+
parser.add_argument(
|
|
1194
|
+
'--identity-only',
|
|
1195
|
+
action='store_true',
|
|
1196
|
+
help='Only calculate the Identity side (Identity users and PrivCloud role members), skipping session monitoring and the groups',
|
|
1197
|
+
)
|
|
1198
|
+
parser.add_argument(
|
|
1199
|
+
'--log-level',
|
|
1200
|
+
default='ERROR',
|
|
1201
|
+
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
|
|
1202
|
+
help='Script log level, logs go to stderr (SDK internal logs stay quiet unless the LOG_LEVEL env variable is set)',
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def setup_and_resolve_timeframe(parser: argparse.ArgumentParser, args: argparse.Namespace) -> tuple:
|
|
1207
|
+
"""Configures the logger and returns the (start, end) timeframe of the parsed common arguments"""
|
|
1208
|
+
get_logger(LOGGER_NAME, logging.getLevelName(args.log_level))
|
|
1209
|
+
# the SDK builds a random browser user agent per client, and fake_useragent warns when it falls back to its built in data
|
|
1210
|
+
logging.getLogger('fake_useragent').setLevel(logging.ERROR)
|
|
1211
|
+
end = _parse_time(args.end) if args.end else datetime.now(timezone.utc).replace(microsecond=0)
|
|
1212
|
+
start = _parse_time(args.start) if args.start else end - timedelta(days=DEFAULT_LOOKBACK_DAYS)
|
|
1213
|
+
if start >= end:
|
|
1214
|
+
parser.error('--start must be before --end')
|
|
1215
|
+
return start, end
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def run_analytics(
|
|
1219
|
+
isp_auth: ArkISPAuth,
|
|
1220
|
+
start: datetime,
|
|
1221
|
+
end: datetime,
|
|
1222
|
+
output_dir: str,
|
|
1223
|
+
identity_only: bool = False,
|
|
1224
|
+
pcloud_by_user_roles: bool = False,
|
|
1225
|
+
max_workers: int = DEFAULT_MAX_WORKERS,
|
|
1226
|
+
active_only: bool = False,
|
|
1227
|
+
include_usernames: bool = False,
|
|
1228
|
+
sessions_workers: int = DEFAULT_SESSIONS_WORKERS,
|
|
1229
|
+
sessions_page_size: Optional[int] = DEFAULT_SESSIONS_PAGE_SIZE,
|
|
1230
|
+
validate_pcloud_vault: bool = False,
|
|
1231
|
+
vault_page_size: int = DEFAULT_VAULT_PAGE_SIZE,
|
|
1232
|
+
) -> None:
|
|
1233
|
+
"""Runs the steps with an authenticated ISP auth and writes the stdout / json / excel output, identity only skips sessions"""
|
|
1234
|
+
usage = Usage()
|
|
1235
|
+
total_steps = (2 if identity_only else 4) + int(active_only)
|
|
1236
|
+
logger.info(f'Step 1/{total_steps}: collecting identity users')
|
|
1237
|
+
user_ids: Dict[str, str] = {}
|
|
1238
|
+
directories = collect_directories(isp_auth)
|
|
1239
|
+
federations = collect_federations(isp_auth)
|
|
1240
|
+
identity_users = collect_identity_users(isp_auth, user_ids)
|
|
1241
|
+
if pcloud_by_user_roles:
|
|
1242
|
+
logger.info(f'Step 2/{total_steps}: collecting PrivCloud users by the roles of each identity user')
|
|
1243
|
+
collect_pcloud_users_by_user_roles(isp_auth, usage, user_ids, max_workers)
|
|
1244
|
+
else:
|
|
1245
|
+
logger.info(f'Step 2/{total_steps}: collecting PrivCloud users by human roles')
|
|
1246
|
+
collect_pcloud_users(isp_auth, usage)
|
|
1247
|
+
logger.info(f'PrivCloud unique role members: [{len(usage.pcloud)}]')
|
|
1248
|
+
if validate_pcloud_vault:
|
|
1249
|
+
logger.info('Validating that the PrivCloud users exist in the vault')
|
|
1250
|
+
validate_pcloud_in_vault(isp_auth, usage, identity_users, vault_page_size)
|
|
1251
|
+
groups = None
|
|
1252
|
+
if identity_only:
|
|
1253
|
+
logger.info('Identity only mode, skipping session monitoring and the groups calculation')
|
|
1254
|
+
else:
|
|
1255
|
+
logger.info(f'Step 3/{total_steps}: collecting SIA ZSP / SCA users by session monitoring')
|
|
1256
|
+
collect_session_users(isp_auth, start, end, usage, sessions_workers, sessions_page_size)
|
|
1257
|
+
if active_only:
|
|
1258
|
+
logger.info(f'Step 4/{total_steps}: excluding inactive users')
|
|
1259
|
+
exclude_inactive_users(isp_auth, usage, identity_users, user_ids, max_workers)
|
|
1260
|
+
logger.info(f'Step {total_steps}/{total_steps}: computing groups')
|
|
1261
|
+
groups = compute_groups(usage, identity_users)
|
|
1262
|
+
if identity_only and active_only:
|
|
1263
|
+
logger.info(f'Step 3/{total_steps}: excluding inactive users')
|
|
1264
|
+
exclude_inactive_users(isp_auth, usage, identity_users, user_ids, max_workers)
|
|
1265
|
+
report = build_report(
|
|
1266
|
+
usage,
|
|
1267
|
+
identity_users,
|
|
1268
|
+
groups,
|
|
1269
|
+
_fmt_time(start),
|
|
1270
|
+
_fmt_time(end),
|
|
1271
|
+
identity_only,
|
|
1272
|
+
active_only,
|
|
1273
|
+
directories,
|
|
1274
|
+
federations,
|
|
1275
|
+
include_usernames,
|
|
1276
|
+
pcloud_by_user_roles,
|
|
1277
|
+
validate_pcloud_vault,
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
1281
|
+
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
1282
|
+
prefix = 'license_consumption_identity' if identity_only else 'license_consumption'
|
|
1283
|
+
json_path = os.path.join(output_dir, f'{prefix}_{stamp}.json')
|
|
1284
|
+
with open(json_path, 'w', encoding='utf-8') as f:
|
|
1285
|
+
json.dump(report, f, indent=2)
|
|
1286
|
+
print_report(report)
|
|
1287
|
+
logger.info(f'JSON written to {json_path}')
|
|
1288
|
+
xlsx_path = os.path.join(output_dir, f'{prefix}_{stamp}.xlsx')
|
|
1289
|
+
write_excel(report, xlsx_path)
|
|
1290
|
+
logger.info(f'Excel written to {xlsx_path}')
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def run_analytics_from_args(isp_auth: ArkISPAuth, start: datetime, end: datetime, args: argparse.Namespace) -> None:
|
|
1294
|
+
"""Runs the analytics with the options of the common arguments"""
|
|
1295
|
+
run_analytics(
|
|
1296
|
+
isp_auth,
|
|
1297
|
+
start,
|
|
1298
|
+
end,
|
|
1299
|
+
args.output_dir,
|
|
1300
|
+
identity_only=args.identity_only,
|
|
1301
|
+
pcloud_by_user_roles=args.pcloud_by_user_roles,
|
|
1302
|
+
max_workers=args.max_workers,
|
|
1303
|
+
active_only=args.active_only,
|
|
1304
|
+
include_usernames=args.include_usernames,
|
|
1305
|
+
sessions_workers=args.sessions_workers,
|
|
1306
|
+
sessions_page_size=args.sessions_page_size,
|
|
1307
|
+
validate_pcloud_vault=args.validate_pcloud_vault,
|
|
1308
|
+
vault_page_size=args.vault_page_size,
|
|
1309
|
+
)
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def main() -> None:
|
|
1313
|
+
parser = argparse.ArgumentParser(description='SCA / SIA ZSP / PrivCloud license consumption analytics')
|
|
1314
|
+
parser.add_argument('--username', required=True, help='Tenant user to authenticate with')
|
|
1315
|
+
parser.add_argument('--secret', help='Secret / password of the user (default: ARK_SECRET env)')
|
|
1316
|
+
parser.add_argument('--service-user', action='store_true', help='Authenticate as an Identity service user instead of a regular user')
|
|
1317
|
+
add_common_arguments(parser)
|
|
1318
|
+
args = parser.parse_args()
|
|
1319
|
+
start, end = setup_and_resolve_timeframe(parser, args)
|
|
1320
|
+
|
|
1321
|
+
secret = args.secret or os.environ.get('ARK_SECRET')
|
|
1322
|
+
if not secret:
|
|
1323
|
+
parser.error('a secret is required, use --secret or the ARK_SECRET env variable')
|
|
1324
|
+
logger.info(
|
|
1325
|
+
f'Starting license consumption analytics for [{args.username}] ({"service user" if args.service_user else "user"}), '
|
|
1326
|
+
f'timeframe [{_fmt_time(start)}] - [{_fmt_time(end)}]'
|
|
1327
|
+
)
|
|
1328
|
+
isp_auth = ArkISPAuth(cache_authentication=False)
|
|
1329
|
+
isp_auth.authenticate(
|
|
1330
|
+
auth_profile=ArkAuthProfile(
|
|
1331
|
+
username=args.username,
|
|
1332
|
+
auth_method=ArkAuthMethod.IdentityServiceUser if args.service_user else ArkAuthMethod.Identity,
|
|
1333
|
+
auth_method_settings=(IdentityServiceUserArkAuthMethodSettings() if args.service_user else IdentityArkAuthMethodSettings()),
|
|
1334
|
+
),
|
|
1335
|
+
secret=ArkSecret(secret=secret),
|
|
1336
|
+
)
|
|
1337
|
+
logger.info('Authenticated successfully')
|
|
1338
|
+
run_analytics_from_args(isp_auth, start, end, args)
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
if __name__ == '__main__':
|
|
1342
|
+
main()
|