diff --git a/CHANGELOG.md b/CHANGELOG.md index 6285c60..04ee0ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Change Log (v2.8.1+) +## v4.4.0 [2025-10-24] + +__What's New:__ + +* Added Manager Approval support to `[application_management|secrets_manager|system]`. +* Added GCP Federation Provider. + +__Enhancements:__ + +* Added `manager_condition` parameter to `[application_management.profiles|secrets_manager|system].policies.build`. +* Drop `socket` usage to speed up response times in specific scenarios, e.g., Windows DNS in WSL environments. + +__Bug Fixes:__ + +* None + +__Dependencies:__ + +* None + +__Other:__ + +* Test naming convention updates. + ## v4.3.2 [2025-09-04] __What's New:__ diff --git a/pyproject.toml b/pyproject.toml index ec998d8..9da742b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ keywords = ["britive", "cpam", "identity", "jit"] [project.optional-dependencies] azure = ["azure-identity"] +gcp = ["google-auth"] [project.urls] Homepage = "https://www.britive.com" diff --git a/src/britive/__init__.py b/src/britive/__init__.py index a36a695..26a6c39 100644 --- a/src/britive/__init__.py +++ b/src/britive/__init__.py @@ -1 +1 @@ -__version__ = '4.3.2' +__version__ = '4.4.0' diff --git a/src/britive/application_management/applications.py b/src/britive/application_management/applications.py index 1c21d70..58571bc 100644 --- a/src/britive/application_management/applications.py +++ b/src/britive/application_management/applications.py @@ -147,7 +147,9 @@ def scan(self, application_id: str, org_scan_only: bool = False) -> dict: :return: Details of the scan that was initiated. """ - return self.britive.application_management.scans.scan(application_id=application_id, org_scan_only=org_scan_only) + return self.britive.application_management.scans.scan( + application_id=application_id, org_scan_only=org_scan_only + ) def delete(self, application_id: str) -> None: """ diff --git a/src/britive/application_management/profiles/policies.py b/src/britive/application_management/profiles/policies.py index 8409519..78c7a6d 100644 --- a/src/britive/application_management/profiles/policies.py +++ b/src/britive/application_management/profiles/policies.py @@ -1,5 +1,5 @@ import json -from typing import Union +from typing import Literal, Union class Policies: @@ -25,6 +25,7 @@ def build( # noqa: PLR0913 access_validity_time: int = 120, approver_users: list = None, approver_tags: list = None, + manager_condition: Literal['All', 'Any', 'Manager'] = '', access_type: str = 'Allow', identifier_type: str = 'name', condition_as_dict: bool = False, @@ -73,6 +74,10 @@ def build( # noqa: PLR0913 If `approval_notification_medium` is set then either `approver_users` or `approver_tags` is required. :param approver_tags: Optional list of tag names who are considered approvers. If `approval_notification_medium` is set then either `approver_users` or `approver_tags` is required. + :param manager_condition: Optional condition to enable requiring user's manager approval. Valid values are + `Any` or `All` or `Manager`. `Any` corresponds to manager approval required, `All` corresponds to + manager and approver_users/approver_tags approval required, and `Manager` corresponds to just the manager's + approval required :param access_type: The type of access this policy provides. Valid values are `Allow` and `Deny`. Defaults to `Allow`. :param identifier_type: Valid values are `id` or `name`. Defaults to `name`. Represents which type of @@ -105,6 +110,7 @@ def build( # noqa: PLR0913 access_validity_time=access_validity_time, approver_users=approver_users, approver_tags=approver_tags, + manager_condition=manager_condition, access_type=access_type, identifier_type=identifier_type, condition_as_dict=condition_as_dict, diff --git a/src/britive/exceptions/__init__.py b/src/britive/exceptions/__init__.py index 779a2f2..4aa9bc9 100644 --- a/src/britive/exceptions/__init__.py +++ b/src/britive/exceptions/__init__.py @@ -49,6 +49,8 @@ class MethodNotAllowed(BritiveException): class MissingAzureDependency(BritiveException): pass +class MissingGcpDependency(BritiveException): + pass class NoSecretsVaultFound(BritiveException): pass @@ -57,6 +59,8 @@ class NoSecretsVaultFound(BritiveException): class NotExecutingInAzureEnvironment(BritiveException): pass +class NotExecutingInGcpEnvironment(BritiveException): + pass class NotExecutingInBitbucketEnvironment(BritiveException): pass diff --git a/src/britive/federation_providers/__init__.py b/src/britive/federation_providers/__init__.py index 1577514..ba95794 100644 --- a/src/britive/federation_providers/__init__.py +++ b/src/britive/federation_providers/__init__.py @@ -3,6 +3,7 @@ from .azure_user_assigned_managed_identity import AzureUserAssignedManagedIdentityFederationProvider from .bitbucket import BitbucketFederationProvider from .federation_provider import FederationProvider +from .gcp import GcpFederationProvider from .github import GithubFederationProvider from .gitlab import GitlabFederationProvider from .spacelift import SpaceliftFederationProvider @@ -14,6 +15,7 @@ def __init__(self, britive) -> None: self.azure_system_assigned_managed_identity = AzureSystemAssignedManagedIdentityFederationProvider(britive) self.azure_user_assigned_managed_identity = AzureUserAssignedManagedIdentityFederationProvider(britive) self.bitbucket = BitbucketFederationProvider(britive) + self.gcp = GcpFederationProvider(britive) self.generic = FederationProvider(britive) self.github = GithubFederationProvider(britive) self.gitlab = GitlabFederationProvider(britive) diff --git a/src/britive/federation_providers/azure_system_assigned_managed_identity.py b/src/britive/federation_providers/azure_system_assigned_managed_identity.py index 13f87d4..7617489 100644 --- a/src/britive/federation_providers/azure_system_assigned_managed_identity.py +++ b/src/britive/federation_providers/azure_system_assigned_managed_identity.py @@ -17,7 +17,8 @@ def get_token(self) -> str: return f'OIDC::{token}' except ImportError as e: raise MissingAzureDependency( - '`azure-identity` package required to use the azure managed identity federation provider' + 'azure dependency package required to use the azure managed identity federation provider, ' + 'install with `pip install britive[azure]' ) from e except CredentialUnavailableError as e: msg = ( diff --git a/src/britive/federation_providers/azure_user_assigned_managed_identity.py b/src/britive/federation_providers/azure_user_assigned_managed_identity.py index b3968a7..32951a6 100644 --- a/src/britive/federation_providers/azure_user_assigned_managed_identity.py +++ b/src/britive/federation_providers/azure_user_assigned_managed_identity.py @@ -18,7 +18,8 @@ def get_token(self) -> str: return f'OIDC::{token}' except ImportError as e: raise MissingAzureDependency( - '`azure-identity` package required to use the azure managed identity federation provider' + 'azure dependency package required to use the azure managed identity federation provider, ' + 'install with `pip install britive[azure]' ) from e except CredentialUnavailableError as e: msg = ( diff --git a/src/britive/federation_providers/gcp.py b/src/britive/federation_providers/gcp.py new file mode 100644 index 0000000..fff0352 --- /dev/null +++ b/src/britive/federation_providers/gcp.py @@ -0,0 +1,30 @@ +from britive.exceptions import MissingGcpDependency, NotExecutingInGcpEnvironment + +from .federation_provider import FederationProvider + + +class GcpFederationProvider(FederationProvider): + def __init__(self, audience: str = None) -> None: + self.audience = audience if audience else 'https://accounts.google.com/' + super().__init__() + + def get_token(self): + try: + from google.auth.exceptions import DefaultCredentialsError + from google.auth.transport.requests import Request + from google.oauth2 import id_token + + token = id_token.fetch_id_token(Request(), self.audience) + + return f'OIDC::{token}' + except ImportError as e: + raise MissingGcpDependency( + 'google dependency package required to use the gcp managed identity federation provider, ' + 'install with `pip install britive[gcp]' + ) from e + except DefaultCredentialsError as e: + msg = ( + 'the codebase is not executing in an Gcp environment or some other issue is causing the ' + 'managed identity credentials to be unavailable' + ) + raise NotExecutingInGcpEnvironment(msg) from e diff --git a/src/britive/helpers/utils.py b/src/britive/helpers/utils.py index 3b4ba02..9870557 100644 --- a/src/britive/helpers/utils.py +++ b/src/britive/helpers/utils.py @@ -1,7 +1,8 @@ -import socket +import warnings from typing import Optional, Union import requests +import urllib3 from britive.exceptions import BritiveException, InvalidFederationProvider, allowed_exceptions from britive.exceptions.badrequest import bad_request_code_map @@ -12,6 +13,7 @@ AzureSystemAssignedManagedIdentityFederationProvider, AzureUserAssignedManagedIdentityFederationProvider, BitbucketFederationProvider, + GcpFederationProvider, GithubFederationProvider, GitlabFederationProvider, SpaceliftFederationProvider, @@ -59,18 +61,20 @@ def pagination_type(headers, result) -> str: return 'none' -def parse_tenant(tenant: str) -> str: - domain = tenant.replace('https://', '').replace('http://', '').split('/')[0] # remove scheme and paths +def parse_tenant(tenant: str, timeout: float = 3) -> str: + if not (domain := urllib3.util.parse_url(tenant).host).endswith('britive-app.com'): + domain = f'{domain}.britive-app.com' try: - socket.getaddrinfo(host=domain, port=443) # if success then a full domain was provided + requests.head(f'https://{domain}/api/health', timeout=timeout) return domain - except socket.gaierror: # assume just the tenant name was provided (originally the only supported method) - resolved_domain = f'{tenant}.britive-app.com' - try: - socket.getaddrinfo(host=resolved_domain, port=443) # validate the hostname is real - return resolved_domain # and if so set the tenant accordingly - except socket.gaierror as e: - raise InvalidTenantError(f'Invalid tenant provided: {tenant}. DNS resolution failed.') from e + except requests.exceptions.Timeout: + original = warnings.formatwarning + warnings.formatwarning = lambda msg, *a, **k: f'{msg}\n' + warnings.warn(f'WARNING: Tenant validation timed out, but domain structure is valid: [{domain}]') + warnings.formatwarning = original + return domain + except requests.exceptions.ConnectionError as e: + raise InvalidTenantError(f'Invalid tenant provided: {tenant}. Domain resolution failed.') from e def response_has_no_content(response) -> bool: @@ -128,6 +132,9 @@ def source_federation_token(provider: str, tenant: Optional[str] = None, duratio `azuresmi-` and `azureumi-|`. If no audience is provided the default audience of `https://management.azure.com/` will be used. + For the GCP provider it is possible to provide an OIDC audience value via + `gcp-`. If no audience is provided the default audience of https://accounts.google.com/ will be used. + For the Github provider it is possible to provide an OIDC audience value via `github-`. If no audience is provided the default Github audience value will be used. @@ -151,6 +158,7 @@ def source_federation_token(provider: str, tenant: Optional[str] = None, duratio profile=safe_list_get(helper, 1), tenant=tenant, duration=duration_seconds ).get_token(), 'bitbucket': lambda: BitbucketFederationProvider().get_token(), + 'gcp': lambda: GcpFederationProvider().get_token(audience=safe_list_get(helper, 1)).get_token(), 'github': lambda: GithubFederationProvider(audience=safe_list_get(helper, 1)).get_token(), 'gitlab': lambda: GitlabFederationProvider(token_env_var=safe_list_get(helper, 1)).get_token(), 'spacelift': lambda: SpaceliftFederationProvider().get_token(), diff --git a/src/britive/secrets_manager/policies.py b/src/britive/secrets_manager/policies.py index c3e4abb..882ce65 100644 --- a/src/britive/secrets_manager/policies.py +++ b/src/britive/secrets_manager/policies.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Literal, Union class PasswordPolicies: @@ -201,6 +201,7 @@ def build( # noqa: PLR0913 access_validity_time: int = 120, approver_users: list = None, approver_tags: list = None, + manager_condition: Literal['All', 'Any', 'Manager'] = '', access_type: str = 'Allow', identifier_type: str = 'name', condition_as_dict: bool = False, @@ -249,6 +250,10 @@ def build( # noqa: PLR0913 If `approval_notification_medium` is set then either `approver_users` or `approver_tags` is required. :param approver_tags: Optional list of tag names who are considered approvers. If `approval_notification_medium` is set then either `approver_users` or `approver_tags` is required. + :param manager_condition: Optional condition to enable requiring user's manager approval. Valid values are + `Any` or `All` or `Manager`. `Any` corresponds to manager approval required, `All` corresponds to + manager and approver_users/approver_tags approval required, and `Manager` corresponds to just the manager's + approval required :param access_type: The type of access this policy provides. Valid values are `Allow` and `Deny`. Defaults to `Allow`. :param identifier_type: Valid values are `id` or `name`. Defaults to `name`. Represents which type of @@ -279,6 +284,7 @@ def build( # noqa: PLR0913 access_validity_time=access_validity_time, approver_users=approver_users, approver_tags=approver_tags, + manager_condition=manager_condition, access_type=access_type, identifier_type=identifier_type, condition_as_dict=condition_as_dict, diff --git a/src/britive/system/policies.py b/src/britive/system/policies.py index 5aa3947..61d6115 100644 --- a/src/britive/system/policies.py +++ b/src/britive/system/policies.py @@ -1,5 +1,5 @@ import json -from typing import Union +from typing import Literal, Union class SystemPolicies: @@ -168,6 +168,7 @@ def build( # noqa: PLR0913 access_validity_time: int = 120, approver_users: list = None, approver_tags: list = None, + manager_condition: Literal['All', 'Any', 'Manager'] = '', access_type: str = 'Allow', identifier_type: str = 'name', condition_as_dict: bool = False, @@ -221,6 +222,10 @@ def build( # noqa: PLR0913 If `approval_notification_medium` is set then either `approver_users` or `approver_tags` is required. :param approver_tags: Optional list of tag names who are considered approvers. If `approval_notification_medium` is set then either `approver_users` or `approver_tags` is required. + :param manager_condition: Optional condition to enable requiring user's manager approval. Valid values are + `Any` or `All` or `Manager`. `Any` corresponds to manager approval required, `All` corresponds to + manager and approver_users/approver_tags approval required, and `Manager` corresponds to just the manager's + approval required :param access_type: The type of access this policy provides. Valid values are `Allow` and `Deny`. Defaults to `Allow`. :param identifier_type: Valid values are `id` or `name`. Defaults to `name`. Represents which type of @@ -247,10 +252,8 @@ def build( # noqa: PLR0913 # handle approval logic if approval_notification_medium: - if not approver_users and not approver_tags: - raise ValueError( - 'when approval is required either approver_tags or approver_users or both must be provided' - ) + if not approver_users and not approver_tags and manager_condition.capitalize() != 'Manager': + raise ValueError('when approval is required either approver_tags or approver_users must be provided') approval_condition = { 'notificationMedium': approval_notification_medium, 'timeToApprove': time_to_approve, @@ -263,6 +266,8 @@ def build( # noqa: PLR0913 approval_condition['approvers'].pop('userIds') if not approver_tags: approval_condition['approvers'].pop('tags') + if manager_condition: + approval_condition['managerApproval'] = {'required': True, 'condition': manager_condition.capitalize()} condition['approval'] = approval_condition diff --git a/tests/000-global_settings-02-notification_mediums.py b/tests/000-global_settings-02-notification_mediums.py index 3deab6c..1d8b72e 100644 --- a/tests/000-global_settings-02-notification_mediums.py +++ b/tests/000-global_settings-02-notification_mediums.py @@ -3,7 +3,7 @@ def test_create(cached_notification_medium): assert isinstance(cached_notification_medium, dict) - assert 'pytest-nm-' in cached_notification_medium['name'] + assert 'pysdktest-nm-' in cached_notification_medium['name'] def test_list(): @@ -20,7 +20,7 @@ def test_get(cached_notification_medium): def test_update(cached_notification_medium): r = str(random.randint(0, 999)) - new_name = f'{cached_notification_medium["name"]}-{r}' + new_name = f'{r}-{cached_notification_medium["name"]}'[:30] britive.global_settings.notification_mediums.update(cached_notification_medium['id'], parameters={'name': new_name}) response = britive.global_settings.notification_mediums.get(cached_notification_medium['id']) assert response['name'] == new_name diff --git a/tests/150-secrets_manager-01-secrets_manager.py b/tests/150-secrets_manager-01-secrets_manager.py index 4c22d4a..2e57d4d 100644 --- a/tests/150-secrets_manager-01-secrets_manager.py +++ b/tests/150-secrets_manager-01-secrets_manager.py @@ -54,7 +54,7 @@ def test_password_policies_list(): def test_password_policies_update(cached_password_policies): r = str(random.randint(0, 999)) - new_name = f'{cached_password_policies["name"]}-{r}' + new_name = f'{r}-{cached_password_policies["name"]}'[:30] britive.secrets_manager.password_policies.update(cached_password_policies['id'], name=new_name) assert britive.secrets_manager.password_policies.get(cached_password_policies['id'])['name'] == new_name