From afa3e113498306275d3758a47ac8c048bbfabaf1 Mon Sep 17 00:00:00 2001 From: Samuel Wan Date: Tue, 7 Jul 2026 12:43:43 -0700 Subject: [PATCH 1/2] fix(approvals): add missing headers param and correct approvals list URL The my_requests and my_approvals approval methods lacked a `headers` argument, so callers could not pass custom headers (e.g. X-On-Behalf-Of) the way my_access/my_resources already allow -- breaking on-behalf-of through the approval flow. Add the optional `headers` argument across my_requests.* and my_approvals.*. Also fix my_requests.list / my_approvals.list to request the approvals collection with a trailing slash (/v1/approvals/); without it the request 302-redirected and was rejected at the edge. --- src/britive/my_approvals.py | 30 ++++++-- src/britive/my_requests.py | 135 ++++++++++++++++++++++++++++++------ 2 files changed, 136 insertions(+), 29 deletions(-) diff --git a/src/britive/my_approvals.py b/src/britive/my_approvals.py index d06b20c..580e21f 100644 --- a/src/britive/my_approvals.py +++ b/src/britive/my_approvals.py @@ -15,41 +15,59 @@ def __init__(self, britive) -> None: self.britive = britive self.base_url = f'{self.britive.base_url}/v1/approvals' - def approve_request(self, request_id: str, comments: str = '') -> None: + def approve_request(self, request_id: str, comments: str = '', headers: dict = None) -> None: """ Approves a request. :param request_id: The ID of the request. :param comments: Approver comments. + :param headers: Any additional headers + Example: + { + "X-On-Behalf-Of": "Bearer ... | user@... | username", + ... + } :return: None. """ params = {'approveRequest': 'yes'} data = {'approverComment': comments} - return self.britive.patch(f'{self.base_url}/{request_id}', params=params, json=data) + return self.britive.patch(f'{self.base_url}/{request_id}', params=params, json=data, headers=headers) - def reject_request(self, request_id: str, comments: str = '') -> None: + def reject_request(self, request_id: str, comments: str = '', headers: dict = None) -> None: """ Rejects a request. :param request_id: The ID of the request. :param comments: Approver comments. + :param headers: Any additional headers + Example: + { + "X-On-Behalf-Of": "Bearer ... | user@... | username", + ... + } :return: None. """ params = {'approveRequest': 'no'} data = {'approverComment': comments} - return self.britive.patch(f'{self.base_url}/{request_id}', params=params, json=data) + return self.britive.patch(f'{self.base_url}/{request_id}', params=params, json=data, headers=headers) - def list(self) -> dict: + def list(self, headers: dict = None) -> dict: """ Lists approval requests. + :param headers: Any additional headers + Example: + { + "X-On-Behalf-Of": "Bearer ... | user@... | username", + ... + } :return: List of approval requests. """ params = {'requestType': 'myApprovals'} - return self.britive.get(f'{self.base_url}', params=params) + return self.britive.get(f'{self.base_url}/', params=params, headers=headers) diff --git a/src/britive/my_requests.py b/src/britive/my_requests.py index 851a1cc..c3eda96 100644 --- a/src/britive/my_requests.py +++ b/src/britive/my_requests.py @@ -27,34 +27,52 @@ def __init__(self, britive) -> None: self.base_url = f'{self.britive.base_url}/v1/approvals' self._helper = HelperMethods(self.britive) - def list(self) -> list: + def list(self, headers: dict = None) -> list: """ List My Requests + :param headers: Any additional headers + Example: + { + "X-On-Behalf-Of": "Bearer ... | user@... | username", + ... + } :return: List of My Requests. """ - return self.britive.get(f'{self.base_url}', params={'requestType': 'myRequests'}) + return self.britive.get(f'{self.base_url}/', params={'requestType': 'myRequests'}, headers=headers) - def approval_request_status(self, request_id: str) -> dict: + def approval_request_status(self, request_id: str, headers: dict = None) -> dict: """ Get the details of an approval request. :param request_id: The ID of the approval request. + :param headers: Any additional headers + Example: + { + "X-On-Behalf-Of": "Bearer ... | user@... | username", + ... + } :return: Details of the approval request. """ - return self.britive.get(f'{self.base_url}/{request_id}') + return self.britive.get(f'{self.base_url}/{request_id}', headers=headers) - def withdraw_approval_request(self, request_id: str) -> None: + def withdraw_approval_request(self, request_id: str, headers: dict = None) -> None: """ Withdraws a pending approval request. :param request_id: The ID of the approval request. + :param headers: Any additional headers + Example: + { + "X-On-Behalf-Of": "Bearer ... | user@... | username", + ... + } :return: None """ - return self._withdraw_approval_request(request_id=request_id) + return self._withdraw_approval_request(request_id=request_id, headers=headers) def _request_approval( self, @@ -68,6 +86,7 @@ def _request_approval( ticket_id: str = None, ticket_type: str = None, wait_time: int = 60, + headers: dict = None, ) -> Any: data = {'justification': justification} @@ -82,7 +101,7 @@ def _request_approval( f'{profile_id}/resources/{entity_id}/approvalRequest' ) ) - request = self.britive.post(url, json=data) + request = self.britive.post(url, json=data, headers=headers) if request is None: raise ProfileCheckoutAlreadyApproved @@ -93,7 +112,7 @@ def _request_approval( try: quit_time = time.time() + max_wait_time while time.time() <= quit_time: - status = self.approval_request_status(request_id=request_id)['status'].lower() + status = self.approval_request_status(request_id=request_id, headers=headers)['status'].lower() if status == 'pending': if progress_func: progress_func('awaiting approval') @@ -106,7 +125,7 @@ def _request_approval( try: # the first ^C we get we will try to withdraw the request time.sleep(1) # give the caller a small window to ^C again - self._withdraw_approval_request(request_id=request_id) + self._withdraw_approval_request(request_id=request_id, headers=headers) raise ProfileApprovalWithdrawn('user interrupt.') from e except KeyboardInterrupt: raise e from None @@ -126,9 +145,12 @@ def _request_approval_by_name( ticket_id: str = None, ticket_type: str = None, wait_time: int = 60, + headers: dict = None, ) -> Any: if entity_type == 'environments': - ids = self._helper.get_profile_and_environment_ids_given_names(profile_name, entity_name, application_name) + ids = self._helper.get_profile_and_environment_ids_given_names( + profile_name, entity_name, application_name, headers=headers + ) return self._request_approval( profile_id=ids['profile_id'], justification=justification, @@ -140,8 +162,9 @@ def _request_approval_by_name( ticket_id=ticket_id, ticket_type=ticket_type, wait_time=wait_time, + headers=headers, ) - ids = self._helper.get_profile_and_resource_ids_given_names(profile_name, entity_name) + ids = self._helper.get_profile_and_resource_ids_given_names(profile_name, entity_name, headers=headers) return self._request_approval( profile_id=ids['profile_id'], justification=justification, @@ -153,14 +176,20 @@ def _request_approval_by_name( ticket_id=ticket_id, ticket_type=ticket_type, wait_time=wait_time, + headers=headers, ) def _withdraw_approval_request( - self, request_id: str = None, profile_id: str = None, entity_id: str = None, entity_type: str = None + self, + request_id: str = None, + profile_id: str = None, + entity_id: str = None, + entity_type: str = None, + headers: dict = None, ) -> None: url = request_id if request_id else f'consumer/{entity_type}/resource?resourceId={profile_id}/{entity_id}' - return self.britive.delete(f'{self.base_url}/{url}') + return self.britive.delete(f'{self.base_url}/{url}', headers=headers) class MyAccessRequests(MyRequests): @@ -176,6 +205,7 @@ def request_approval_by_name( ticket_id: str = None, ticket_type: str = None, wait_time: int = 60, + headers: dict = None, ) -> Any: """ Requests approval to checkout a profile at a later time, using names of entities instead of IDs. @@ -200,6 +230,12 @@ def request_approval_by_name( :param ticket_type: Optional ITSM ticket type or category :param wait_time: The number of seconds to sleep/wait between polling to check if the profile checkout was approved. Only applicable if `block_until_disposition = True`. + :param headers: Any additional headers + Example: + { + "X-On-Behalf-Of": "Bearer ... | user@... | username", + ... + } :return: If `block_until_disposition = True` then returns the final status of the request. If `block_until_disposition = False` then returns details about the approval request. :raises ProfileApprovalMaxBlockTimeExceeded: if max_wait_time has been reached while waiting for approval. @@ -217,6 +253,7 @@ def request_approval_by_name( ticket_id=ticket_id, ticket_type=ticket_type, wait_time=wait_time, + headers=headers, ) def request_approval( @@ -230,6 +267,7 @@ def request_approval( ticket_id: str = None, ticket_type: str = None, wait_time: int = 60, + headers: dict = None, ) -> Any: """ Requests approval to checkout a profile at a later time. @@ -252,6 +290,12 @@ def request_approval( :param ticket_type: Optional ITSM ticket type or category :param wait_time: The number of seconds to sleep/wait between polling to check if the profile checkout was approved. Only applicable if `block_until_disposition = True`. + :param headers: Any additional headers + Example: + { + "X-On-Behalf-Of": "Bearer ... | user@... | username", + ... + } :return: If `block_until_disposition = True` then returns the final status of the request. If `block_until_disposition = False` then returns details about the approval request. :raises ProfileApprovalMaxBlockTimeExceeded: if max_wait_time has been reached while waiting for approval. @@ -268,10 +312,11 @@ def request_approval( ticket_id=ticket_id, ticket_type=ticket_type, wait_time=wait_time, + headers=headers, ) def withdraw_approval_request_by_name( - self, profile_name: str, environment_name: str = None, application_name: str = None + self, profile_name: str, environment_name: str = None, application_name: str = None, headers: dict = None ) -> None: """ Withdraws a pending approval request, using names of entities instead of IDs. @@ -280,17 +325,25 @@ def withdraw_approval_request_by_name( :param environment_name: The name of the environment. Use `list_profiles()` to obtain the eligible environments. :param application_name: Optionally the name of the application, which can help disambiguate between profiles with the same name across applications. + :param headers: Any additional headers + Example: + { + "X-On-Behalf-Of": "Bearer ... | user@... | username", + ... + } :return: None """ - ids = self._helper.get_profile_and_environment_ids_given_names(profile_name, environment_name, application_name) + ids = self._helper.get_profile_and_environment_ids_given_names( + profile_name, environment_name, application_name, headers=headers + ) return self._withdraw_approval_request( - profile_id=ids['profile_id'], entity_id=ids['environment_id'], entity_type='papservice' + profile_id=ids['profile_id'], entity_id=ids['environment_id'], entity_type='papservice', headers=headers ) def withdraw_approval_request( - self, request_id: str = None, profile_id: str = None, environment_id: str = None + self, request_id: str = None, profile_id: str = None, environment_id: str = None, headers: dict = None ) -> None: """ Withdraws a pending approval request. @@ -300,13 +353,19 @@ def withdraw_approval_request( :param request_id: The ID of the approval request. :param profile_id: The ID of the profile. :param environment_id: The ID of the environment. + :param headers: Any additional headers + Example: + { + "X-On-Behalf-Of": "Bearer ... | user@... | username", + ... + } :return: None """ if not request_id and not all([profile_id, environment_id]): raise ValueError('profile_id and environment_id are required') return self._withdraw_approval_request( - profile_id=profile_id, entity_id=environment_id, entity_type='papservice' + profile_id=profile_id, entity_id=environment_id, entity_type='papservice', headers=headers ) @@ -322,6 +381,7 @@ def request_approval( ticket_id: str = None, ticket_type: str = None, wait_time: int = 60, + headers: dict = None, ) -> Any: """ Requests approval to checkout a profile at a later time. @@ -344,6 +404,12 @@ def request_approval( :param ticket_type: Optional ITSM ticket type or category :param wait_time: The number of seconds to sleep/wait between polling to check if the profile checkout was approved. Only applicable if `block_until_disposition = True`. + :param headers: Any additional headers + Example: + { + "X-On-Behalf-Of": "Bearer ... | user@... | username", + ... + } :return: If `block_until_disposition = True` then returns the final status of the request. If `block_until_disposition = False` then returns details about the approval request. :raises ProfileApprovalMaxBlockTimeExceeded: if max_wait_time has been reached while waiting for approval. @@ -360,6 +426,7 @@ def request_approval( ticket_id=ticket_id, ticket_type=ticket_type, wait_time=wait_time, + headers=headers, ) def request_approval_by_name( @@ -373,6 +440,7 @@ def request_approval_by_name( ticket_id: str = None, ticket_type: str = None, wait_time: int = 60, + headers: dict = None, ) -> Any: """ Requests approval to checkout a profile at a later time, using names of entities instead of IDs. @@ -395,6 +463,12 @@ def request_approval_by_name( :param ticket_type: Optional ITSM ticket type or category :param wait_time: The number of seconds to sleep/wait between polling to check if the profile checkout was approved. Only applicable if `block_until_disposition = True`. + :param headers: Any additional headers + Example: + { + "X-On-Behalf-Of": "Bearer ... | user@... | username", + ... + } :return: If `block_until_disposition = True` then returns the final status of the request. If `block_until_disposition = False` then returns details about the approval request. :raises ProfileApprovalMaxBlockTimeExceeded: if max_wait_time has been reached while waiting for approval. @@ -411,25 +485,34 @@ def request_approval_by_name( ticket_id=ticket_id, ticket_type=ticket_type, wait_time=wait_time, + headers=headers, ) - def withdraw_approval_request_by_name(self, profile_name: str, resource_name: str = None) -> None: + def withdraw_approval_request_by_name( + self, profile_name: str, resource_name: str = None, headers: dict = None + ) -> None: """ Withdraws a pending approval request, using names of entities instead of IDs. :param profile_name: The name of the profile. Use `list_profiles()` to obtain the eligible profiles. :param resource_name: The name of the resource. Use `list_profiles()` to obtain the eligible resources. + :param headers: Any additional headers + Example: + { + "X-On-Behalf-Of": "Bearer ... | user@... | username", + ... + } :return: None """ - ids = self._helper.get_profile_and_resource_ids_given_names(profile_name, resource_name) + ids = self._helper.get_profile_and_resource_ids_given_names(profile_name, resource_name, headers=headers) return self._withdraw_approval_request( - profile_id=ids['profile_id'], entity_id=ids['resource_id'], entity_type='resourceprofile' + profile_id=ids['profile_id'], entity_id=ids['resource_id'], entity_type='resourceprofile', headers=headers ) def withdraw_approval_request( - self, request_id: str = None, profile_id: str = None, resource_id: str = None + self, request_id: str = None, profile_id: str = None, resource_id: str = None, headers: dict = None ) -> None: """ Withdraws a pending approval request. @@ -439,6 +522,12 @@ def withdraw_approval_request( :param request_id: The ID of the approval request. :param profile_id: The ID of the profile. :param resource_id: The ID of the resource. + :param headers: Any additional headers + Example: + { + "X-On-Behalf-Of": "Bearer ... | user@... | username", + ... + } :return: None """ @@ -446,5 +535,5 @@ def withdraw_approval_request( raise ValueError('profile_id and resource_id are required') return self._withdraw_approval_request( - profile_id=profile_id, entity_id=resource_id, entity_type='resourceprofile' + profile_id=profile_id, entity_id=resource_id, entity_type='resourceprofile', headers=headers ) From 7be5e7666ecbe63b82b076f44e5f10e8003b18f5 Mon Sep 17 00:00:00 2001 From: Samuel Wan Date: Tue, 7 Jul 2026 12:43:43 -0700 Subject: [PATCH 2/2] v4.6.1 --- CHANGELOG.md | 19 +++++++++++++++++++ src/britive/__init__.py | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c12e88c..5a233d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Change Log (v2.8.1+) +## v4.6.1 [2026-07-07] + +__Enhancements:__ + +* None + +__Bug Fixes:__ + +* Added the missing optional `headers` argument to `my_requests.[list|approval_request_status|request_approval|request_approval_by_name|withdraw_approval_request|withdraw_approval_request_by_name]` and `my_approvals.[list|approve_request|reject_request]`, so custom headers (e.g. `X-On-Behalf-Of`) propagate through the approval flow as they already do for `my_access`/`my_resources`. +* Fixed `my_requests.list` and `my_approvals.list` to request the approvals collection with a trailing slash (`/v1/approvals/`); without it the request 302-redirected and was rejected at the edge. + +__Dependencies:__ + +* None + +__Other:__ + +* None + ## v4.6.0 [2026-05-27] __What's New:__ diff --git a/src/britive/__init__.py b/src/britive/__init__.py index 52fde38..e893b1f 100644 --- a/src/britive/__init__.py +++ b/src/britive/__init__.py @@ -1 +1 @@ -__version__ = '4.6.0' +__version__ = '4.6.1'