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.
Files changed (212) hide show
  1. ark_sdk_python/actions/ark_exec_action.py +1 -0
  2. ark_sdk_python/ark_api.py +60 -0
  3. ark_sdk_python/auth/ark_auth.py +6 -1
  4. ark_sdk_python/auth/identity/ark_identity_fqdn_resolver.py +4 -4
  5. ark_sdk_python/common/ark_version.py +1 -1
  6. ark_sdk_python/common/isp/ark_isp_service_client.py +3 -1
  7. ark_sdk_python/examples/license_consumption_analytics.py +1342 -0
  8. ark_sdk_python/examples/validate_ssh_connection.py +11 -4
  9. ark_sdk_python/models/actions/services/__init__.py +6 -0
  10. ark_sdk_python/models/actions/services/ark_access_exec_action_consts.py +20 -0
  11. ark_sdk_python/models/actions/services/ark_ars_exec_action_consts.py +40 -0
  12. ark_sdk_python/models/actions/services/ark_cmgr_exec_action_consts.py +45 -1
  13. ark_sdk_python/models/actions/services/ark_identity_exec_action_consts.py +17 -0
  14. ark_sdk_python/models/actions/services/ark_pcloud_exec_action_consts.py +45 -0
  15. ark_sdk_python/models/actions/services/ark_sia_exec_action_consts.py +80 -4
  16. ark_sdk_python/models/common/ark_application_code.py +5 -0
  17. ark_sdk_python/models/common/ark_os_type.py +1 -0
  18. ark_sdk_python/models/common/ark_region.py +2 -1
  19. ark_sdk_python/models/common/identity/ark_identity_directory_schemas.py +2 -0
  20. ark_sdk_python/models/services/access/__init__.py +15 -0
  21. ark_sdk_python/models/services/access/assets/__init__.py +13 -0
  22. ark_sdk_python/models/services/access/assets/ark_access_assets_asset.py +61 -0
  23. ark_sdk_python/models/services/access/assets/ark_access_assets_assets.py +15 -0
  24. ark_sdk_python/models/services/access/assets/ark_access_assets_list_assets.py +32 -0
  25. ark_sdk_python/models/services/access/assets/ark_access_assets_secret.py +15 -0
  26. ark_sdk_python/models/services/ars/__init__.py +0 -0
  27. ark_sdk_python/models/services/ars/common/__init__.py +9 -0
  28. ark_sdk_python/models/services/ars/common/ark_ars_filter.py +14 -0
  29. ark_sdk_python/models/services/ars/common/ark_ars_query.py +22 -0
  30. ark_sdk_python/models/services/ars/requests/__init__.py +31 -0
  31. ark_sdk_python/models/services/ars/requests/ark_ars_access_request.py +82 -0
  32. ark_sdk_python/models/services/ars/requests/ark_ars_access_request_async_task.py +44 -0
  33. ark_sdk_python/models/services/ars/requests/ark_ars_access_request_form.py +67 -0
  34. ark_sdk_python/models/services/ars/requests/ark_ars_access_request_info.py +23 -0
  35. ark_sdk_python/models/services/ars/requests/ark_ars_access_requests_stats.py +17 -0
  36. ark_sdk_python/models/services/ars/requests/ark_ars_cancel_access_request.py +8 -0
  37. ark_sdk_python/models/services/ars/requests/ark_ars_finalize_access_request.py +20 -0
  38. ark_sdk_python/models/services/ars/requests/ark_ars_get_access_request.py +7 -0
  39. ark_sdk_python/models/services/ars/requests/ark_ars_list_access_request_forms.py +12 -0
  40. ark_sdk_python/models/services/ars/requests/ark_ars_request_type.py +6 -0
  41. ark_sdk_python/models/services/ars/requests/ark_ars_submit_access_request.py +18 -0
  42. ark_sdk_python/models/services/ars/requests/ark_target_category.py +32 -0
  43. ark_sdk_python/models/services/cmgr/__init__.py +59 -3
  44. ark_sdk_python/models/services/cmgr/ark_cmgr_component.py +82 -0
  45. ark_sdk_python/models/services/cmgr/ark_cmgr_component_operation_async_task.py +31 -0
  46. ark_sdk_python/models/services/cmgr/ark_cmgr_components_filter.py +16 -0
  47. ark_sdk_python/models/services/cmgr/ark_cmgr_components_stats.py +15 -0
  48. ark_sdk_python/models/services/cmgr/ark_cmgr_connector.py +52 -0
  49. ark_sdk_python/models/services/cmgr/ark_cmgr_connector_components_filter.py +7 -0
  50. ark_sdk_python/models/services/cmgr/ark_cmgr_connector_setup_script.py +8 -0
  51. ark_sdk_python/models/services/cmgr/ark_cmgr_connectors_filter.py +34 -0
  52. ark_sdk_python/models/services/cmgr/ark_cmgr_connectors_stats.py +31 -0
  53. ark_sdk_python/models/services/cmgr/ark_cmgr_delete_connector.py +24 -0
  54. ark_sdk_python/models/services/cmgr/ark_cmgr_get_connector.py +7 -0
  55. ark_sdk_python/models/services/cmgr/ark_cmgr_get_connector_component.py +8 -0
  56. ark_sdk_python/models/services/cmgr/ark_cmgr_get_connector_setup_script.py +23 -0
  57. ark_sdk_python/models/services/cmgr/ark_cmgr_install_component.py +12 -0
  58. ark_sdk_python/models/services/cmgr/ark_cmgr_install_connector.py +28 -0
  59. ark_sdk_python/models/services/cmgr/ark_cmgr_list_connector_components.py +7 -0
  60. ark_sdk_python/models/services/cmgr/ark_cmgr_reinstall_component.py +8 -0
  61. ark_sdk_python/models/services/cmgr/ark_cmgr_rescan_components.py +7 -0
  62. ark_sdk_python/models/services/cmgr/ark_cmgr_uninstall_component.py +16 -0
  63. ark_sdk_python/models/services/cmgr/ark_cmgr_uninstall_connector.py +21 -0
  64. ark_sdk_python/models/services/cmgr/ark_cmgr_upgrade_component.py +9 -0
  65. ark_sdk_python/models/services/cmgr/ark_cmgr_upgrade_connector.py +7 -0
  66. ark_sdk_python/models/services/cmgr/ark_cmgr_workspace_type_serializer.py +18 -0
  67. ark_sdk_python/models/services/identity/directories/ark_identity_directory.py +4 -0
  68. ark_sdk_python/models/services/identity/federations/__init__.py +15 -0
  69. ark_sdk_python/models/services/identity/federations/ark_identity_federation.py +75 -0
  70. ark_sdk_python/models/services/identity/federations/ark_identity_federations_filter.py +10 -0
  71. ark_sdk_python/models/services/identity/federations/ark_identity_get_federation.py +7 -0
  72. ark_sdk_python/models/services/identity/users/__init__.py +8 -0
  73. ark_sdk_python/models/services/identity/users/ark_identity_get_user_state.py +17 -0
  74. ark_sdk_python/models/services/identity/users/ark_identity_list_user_roles.py +16 -0
  75. ark_sdk_python/models/services/identity/users/ark_identity_user_role.py +12 -0
  76. ark_sdk_python/models/services/identity/users/ark_identity_user_state.py +20 -0
  77. ark_sdk_python/models/services/pcloud/usermgmt/__init__.py +65 -0
  78. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_activate_vault_user.py +7 -0
  79. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_add_vault_group.py +5 -0
  80. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_add_vault_member_to_group.py +15 -0
  81. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_add_vault_user.py +18 -0
  82. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_delete_vault_group.py +7 -0
  83. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_delete_vault_user.py +7 -0
  84. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_disable_vault_user.py +7 -0
  85. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_enable_vault_user.py +7 -0
  86. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_get_vault_group.py +7 -0
  87. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_get_vault_user.py +7 -0
  88. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_remove_vault_member_from_group.py +10 -0
  89. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_update_vault_group.py +10 -0
  90. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_update_vault_user.py +18 -0
  91. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_vault_group.py +22 -0
  92. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_vault_groups_filter.py +12 -0
  93. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_vault_groups_stats.py +10 -0
  94. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_vault_user.py +96 -0
  95. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_vault_users_filter.py +22 -0
  96. ark_sdk_python/models/services/pcloud/usermgmt/ark_pcloud_vault_users_stats.py +12 -0
  97. ark_sdk_python/models/services/sia/access/__init__.py +54 -0
  98. ark_sdk_python/models/services/sia/access/ark_sia_connector.py +119 -0
  99. ark_sdk_python/models/services/sia/access/ark_sia_connectors_filter.py +39 -0
  100. ark_sdk_python/models/services/sia/access/ark_sia_connectors_stats.py +33 -0
  101. ark_sdk_python/models/services/sia/access/ark_sia_delete_connector.py +25 -0
  102. ark_sdk_python/models/services/sia/access/ark_sia_delete_https_relay.py +31 -0
  103. ark_sdk_python/models/services/sia/access/ark_sia_get_connector.py +7 -0
  104. ark_sdk_python/models/services/sia/access/ark_sia_get_connector_setup_script.py +4 -0
  105. ark_sdk_python/models/services/sia/access/ark_sia_get_https_relay.py +7 -0
  106. ark_sdk_python/models/services/sia/access/ark_sia_get_https_relay_setup_script.py +19 -0
  107. ark_sdk_python/models/services/sia/access/ark_sia_https_relay.py +34 -0
  108. ark_sdk_python/models/services/sia/access/ark_sia_https_relay_setup_script.py +8 -0
  109. ark_sdk_python/models/services/sia/access/ark_sia_https_relay_status_codes.py +14 -0
  110. ark_sdk_python/models/services/sia/access/ark_sia_https_relays_filter.py +13 -0
  111. ark_sdk_python/models/services/sia/access/ark_sia_install_connector.py +13 -2
  112. ark_sdk_python/models/services/sia/access/ark_sia_install_relay.py +32 -0
  113. ark_sdk_python/models/services/sia/access/ark_sia_k8s_details.py +36 -0
  114. ark_sdk_python/models/services/sia/access/ark_sia_rotate_connector.py +7 -0
  115. ark_sdk_python/models/services/sia/access/ark_sia_rotate_https_relay.py +7 -0
  116. ark_sdk_python/models/services/sia/access/ark_sia_uninstall_connector.py +15 -3
  117. ark_sdk_python/models/services/sia/access/ark_sia_uninstall_relay.py +23 -0
  118. ark_sdk_python/models/services/sia/access/ark_sia_update_connector_maintenance_mode.py +23 -0
  119. ark_sdk_python/models/services/sia/access/ark_sia_upgrade_connector.py +7 -0
  120. ark_sdk_python/models/services/sia/access/ark_sia_upgrade_connector_async_task.py +32 -0
  121. ark_sdk_python/models/services/sia/access/ark_sia_upgrade_https_relay.py +7 -0
  122. ark_sdk_python/models/services/sia/k8s/__init__.py +11 -1
  123. ark_sdk_python/models/services/sia/k8s/ark_sia_k8s_cluster.py +22 -0
  124. ark_sdk_python/models/services/sia/k8s/ark_sia_k8s_clusters.py +10 -0
  125. ark_sdk_python/models/services/sia/k8s/ark_sia_k8s_generate_cluster_config.py +13 -0
  126. ark_sdk_python/models/services/sia/k8s/ark_sia_k8s_list_clusters.py +9 -0
  127. ark_sdk_python/models/services/sia/secrets/db/__init__.py +22 -6
  128. ark_sdk_python/models/services/sia/secrets/db/ark_sia_db_add_secret.py +54 -3
  129. ark_sdk_python/models/services/sia/secrets/db/ark_sia_db_delete_secret.py +4 -0
  130. ark_sdk_python/models/services/sia/secrets/db/ark_sia_db_get_secret.py +4 -0
  131. ark_sdk_python/models/services/sia/secrets/db/ark_sia_db_list_strong_accounts_response.py +16 -0
  132. ark_sdk_python/models/services/sia/secrets/db/ark_sia_db_secret_metadata.py +27 -2
  133. ark_sdk_python/models/services/sia/secrets/db/ark_sia_db_secrets_filter.py +7 -0
  134. ark_sdk_python/models/services/sia/secrets/db/ark_sia_db_strong_accounts_filter.py +29 -0
  135. ark_sdk_python/models/services/sia/secrets/db/ark_sia_db_update_secret.py +51 -3
  136. ark_sdk_python/models/services/sia/settings/__init__.py +2 -0
  137. ark_sdk_python/models/services/sia/settings/ark_sia_settings_feature_name.py +1 -0
  138. ark_sdk_python/models/services/sia/settings/ark_sia_settings_https_relay.py +11 -0
  139. ark_sdk_python/models/services/sia/settings/ark_sia_settings_list_settings.py +4 -1
  140. ark_sdk_python/models/services/sia/settings/ark_sia_settings_set_settings.py +2 -0
  141. ark_sdk_python/models/services/sia/settings/ark_sia_settings_setting_types.py +2 -0
  142. ark_sdk_python/models/services/sia/settings/ark_sia_settings_settings.py +2 -0
  143. ark_sdk_python/models/services/sia/shortened_connection_string/__init__.py +11 -0
  144. ark_sdk_python/models/services/sia/shortened_connection_string/ark_sia_generate_shortened_connection_string.py +7 -0
  145. ark_sdk_python/models/services/sia/shortened_connection_string/ark_sia_generate_shortened_connection_string_response.py +8 -0
  146. ark_sdk_python/models/services/sia/sso/ark_sia_sso_get_short_lived_rdp_file.py +16 -2
  147. ark_sdk_python/models/services/sia/sso/ark_sia_sso_get_ssh_key.py +1 -0
  148. ark_sdk_python/models/services/sia/workspaces/db/__init__.py +27 -8
  149. ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_add_database.py +152 -0
  150. ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_database.py +101 -2
  151. ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_database_info.py +47 -1
  152. ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_databases_filter.py +13 -0
  153. ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_databases_stats.py +12 -0
  154. ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_delete_database.py +13 -0
  155. ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_get_database.py +13 -0
  156. ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_provider.py +25 -1
  157. ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_target_constraints.py +39 -0
  158. ark_sdk_python/models/services/sia/workspaces/db/ark_sia_db_update_database.py +166 -0
  159. ark_sdk_python/models/services/sm/ark_sm_session.py +1 -0
  160. ark_sdk_python/models/services/sm/ark_sm_sessions_filter.py +7 -0
  161. ark_sdk_python/models/services/uap/common/__init__.py +2 -0
  162. ark_sdk_python/models/services/uap/common/ark_uap_access_approval.py +24 -0
  163. ark_sdk_python/models/services/uap/common/ark_uap_change_info.py +1 -1
  164. ark_sdk_python/models/services/uap/common/ark_uap_conditions.py +15 -1
  165. ark_sdk_python/models/services/uap/common/ark_uap_metadata.py +1 -1
  166. ark_sdk_python/models/services/uap/common/ark_uap_principal.py +2 -2
  167. ark_sdk_python/models/services/uap/sia/common/ark_uap_sia_common_conditions.py +14 -0
  168. ark_sdk_python/models/services/uap/sia/vm/__init__.py +2 -0
  169. ark_sdk_python/models/services/uap/sia/vm/ark_uap_sia_vm_access_policy.py +11 -0
  170. ark_sdk_python/models/services/uap/sia/vm/ark_uap_sia_vm_conditions.py +28 -0
  171. ark_sdk_python/services/access/__init__.py +3 -0
  172. ark_sdk_python/services/access/ark_access_api.py +17 -0
  173. ark_sdk_python/services/access/assets/__init__.py +3 -0
  174. ark_sdk_python/services/access/assets/ark_access_assets_service.py +91 -0
  175. ark_sdk_python/services/ars/__init__.py +0 -0
  176. ark_sdk_python/services/ars/requests/__init__.py +7 -0
  177. ark_sdk_python/services/ars/requests/ark_ars_requests_async_request.py +145 -0
  178. ark_sdk_python/services/ars/requests/ark_ars_requests_service.py +377 -0
  179. ark_sdk_python/services/cmgr/__init__.py +2 -1
  180. ark_sdk_python/services/cmgr/ark_cmgr_component_operation_async_request.py +139 -0
  181. ark_sdk_python/services/cmgr/ark_cmgr_service.py +754 -3
  182. ark_sdk_python/services/identity/ark_identity_api.py +12 -0
  183. ark_sdk_python/services/identity/common/ark_identity_base_service.py +1 -1
  184. ark_sdk_python/services/identity/directories/ark_identity_directories_service.py +4 -1
  185. ark_sdk_python/services/identity/federations/__init__.py +3 -0
  186. ark_sdk_python/services/identity/federations/ark_identity_federations_service.py +218 -0
  187. ark_sdk_python/services/identity/users/ark_identity_users_service.py +76 -1
  188. ark_sdk_python/services/pcloud/ark_pcloud_api.py +12 -0
  189. ark_sdk_python/services/pcloud/common/ark_pcloud_base_service.py +1 -1
  190. ark_sdk_python/services/pcloud/usermgmt/__init__.py +3 -0
  191. ark_sdk_python/services/pcloud/usermgmt/ark_pcloud_usermgmt_service.py +513 -0
  192. ark_sdk_python/services/sia/access/__init__.py +2 -1
  193. ark_sdk_python/services/sia/access/ark_sia_access_service.py +693 -17
  194. ark_sdk_python/services/sia/access/ark_sia_upgrade_connector_async_request.py +136 -0
  195. ark_sdk_python/services/sia/ark_sia_api.py +12 -0
  196. ark_sdk_python/services/sia/k8s/ark_sia_k8s_service.py +77 -1
  197. ark_sdk_python/services/sia/secrets/db/ark_sia_db_secrets_service.py +354 -1
  198. ark_sdk_python/services/sia/settings/ark_sia_settings_service.py +60 -2
  199. ark_sdk_python/services/sia/shortened_connection_string/__init__.py +7 -0
  200. ark_sdk_python/services/sia/shortened_connection_string/ark_sia_shortened_connection_string_service.py +75 -0
  201. ark_sdk_python/services/sia/sso/ark_sia_sso_service.py +12 -2
  202. ark_sdk_python/services/sia/sso/consts.py +9 -0
  203. ark_sdk_python/services/sia/workspaces/db/ark_sia_db_workspace_service.py +346 -7
  204. ark_sdk_python/services/sm/ark_sm_service.py +4 -1
  205. ark_sdk_python/services/uap/common/ark_uap_base_service.py +13 -6
  206. ark_sdk_python/services/uap/sca/ark_uap_sca_service.py +4 -4
  207. {ark_sdk_python-2.1.4.dist-info → ark_sdk_python-2.2.0.dist-info}/METADATA +6 -1
  208. {ark_sdk_python-2.1.4.dist-info → ark_sdk_python-2.2.0.dist-info}/RECORD +212 -84
  209. {ark_sdk_python-2.1.4.dist-info → ark_sdk_python-2.2.0.dist-info}/WHEEL +1 -1
  210. {ark_sdk_python-2.1.4.dist-info → ark_sdk_python-2.2.0.dist-info}/licenses/NOTICES.md +371 -0
  211. {ark_sdk_python-2.1.4.dist-info → ark_sdk_python-2.2.0.dist-info}/entry_points.txt +0 -0
  212. {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()