made custom headers be available to async aws signer (#863)

* made custom headers be available to async aws signer

Signed-off-by: Bruno Murino <brunomurino@users.noreply.github.com>

* updated changelog

Signed-off-by: Bruno Murino <brunomurino@users.noreply.github.com>

* added tests for using host header for AWS request signature on both sync and async clients

Signed-off-by: Bruno Murino <brunomurino@users.noreply.github.com>

* added documentation guide about aws auth when accessing via tunnel

Signed-off-by: Bruno Murino <brunomurino@users.noreply.github.com>

---------

Signed-off-by: Bruno Murino <brunomurino@users.noreply.github.com>
This commit is contained in:
Bruno Murino
2024-12-01 13:31:51 +00:00
committed by GitHub
parent 090e11eee8
commit 6f761abf5e
6 changed files with 105 additions and 6 deletions
+1
View File
@@ -3,6 +3,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
## [Unreleased]
### Added
- Added option to pass custom headers to 'AWSV4SignerAsyncAuth' ([863](https://github.com/opensearch-project/opensearch-py/pull/863))
### Updated APIs
### Changed
### Deprecated
+38
View File
@@ -2,6 +2,7 @@
- [IAM Authentication](#iam-authentication)
- [IAM Authentication with a Synchronous Client](#iam-authentication-with-a-synchronous-client)
- [IAM Authentication with an Async Client](#iam-authentication-with-an-async-client)
- [IAM Authentication via Tunnel](#iam-authentication-via-tunnel)
- [Kerberos](#kerberos)
# Authentication
@@ -104,6 +105,43 @@ async def search():
search()
```
## IAM Authentication via Tunnel
If you're accessing OpenSearch via SSH or SSM tunnel, then you need to specify the Host to be used for signing the AWS requests by passing a "Host" header, like so:
```python
from opensearchpy import OpenSearch, RequestsHttpConnection, RequestsAWSV4SignerAuth, AsyncOpenSearch, AsyncHttpConnection, AWSV4SignerAsyncAuth
import boto3
host = 'localhost' # local endpoint used by the SSH/SSM tunnel
port = 8443
signature_host = 'my-test-domain.eu-west-1.es.amazonaws.com:443' # this needs to be the real host provided by AWS
region = 'eu-west-1'
service = 'es' # 'aoss' for OpenSearch Serverless
credentials = boto3.Session().get_credentials()
# Sync
client = OpenSearch(
hosts = [{'host': host, 'port': port, 'headers': {'host': signature_host}}],
http_auth = RequestsAWSV4SignerAuth(credentials, region, service),
use_ssl = True,
verify_certs = True,
connection_class = RequestsHttpConnection,
pool_maxsize = 20
)
# Async
async_client = AsyncOpenSearch(
hosts = [{'host': host, 'port': port, 'headers': {'host': signature_host}}],
http_auth = AWSV4SignerAsyncAuth(credentials, region, service),
use_ssl = True,
verify_certs = True,
connection_class = AsyncHttpConnection
)
```
## Kerberos
There are several python packages that provide Kerberos support over HTTP, such as [requests-kerberos](http://pypi.org/project/requests-kerberos) and [requests-gssapi](https://pypi.org/project/requests-gssapi). The following example shows how to setup Kerberos authentication.
+30 -2
View File
@@ -8,6 +8,7 @@
# GitHub history for details.
from typing import Any, Dict, Optional, Union
from urllib.parse import parse_qs, urlencode, urlparse
class AWSV4SignerAsyncAuth:
@@ -34,8 +35,9 @@ class AWSV4SignerAsyncAuth:
url: str,
query_string: Optional[str] = None,
body: Optional[Union[str, bytes]] = None,
headers: Optional[Dict[str, str]] = None,
) -> Dict[str, str]:
return self._sign_request(method, url, query_string, body)
return self._sign_request(method, url, query_string, body, headers)
def _sign_request(
self,
@@ -43,6 +45,7 @@ class AWSV4SignerAsyncAuth:
url: str,
query_string: Optional[str],
body: Optional[Union[str, bytes]],
headers: Optional[Dict[str, str]],
) -> Dict[str, str]:
"""
This method helps in signing the request by injecting the required headers.
@@ -53,10 +56,12 @@ class AWSV4SignerAsyncAuth:
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
signature_host = self._fetch_url(url, headers or dict())
# create an AWS request object and sign it using SigV4Auth
aws_request = AWSRequest(
method=method,
url=url,
url=signature_host,
data=body,
)
@@ -80,3 +85,26 @@ class AWSV4SignerAsyncAuth:
# copy the headers from AWS request object into the prepared_request
return dict(aws_request.headers.items())
def _fetch_url(self, url: str, headers: Optional[Dict[str, str]]) -> str:
"""
This is a util method that helps in reconstructing the request url.
:param prepared_request: unsigned request
:return: reconstructed url
"""
parsed_url = urlparse(url)
path = parsed_url.path or "/"
# fetch the query string if present in the request
querystring = ""
if parsed_url.query:
querystring = "?" + urlencode(
parse_qs(parsed_url.query, keep_blank_values=True), doseq=True
)
# fetch the host information from headers
headers = {key.lower(): value for key, value in (headers or dict()).items()}
location = headers.get("host") or parsed_url.netloc
# construct the url and return
return parsed_url.scheme + "://" + location + path + querystring
+4 -4
View File
@@ -92,14 +92,14 @@ class RequestsAWSV4SignerAuth(requests.auth.AuthBase):
prepared_request.headers.update(
self.signer.sign(
prepared_request.method,
self._fetch_url(prepared_request), # type: ignore
self._fetch_url(prepared_request),
prepared_request.body,
)
)
return prepared_request
def _fetch_url(self, prepared_request): # type: ignore
def _fetch_url(self, prepared_request: requests.PreparedRequest) -> str:
"""
This is a util method that helps in reconstructing the request url.
:param prepared_request: unsigned request
@@ -112,7 +112,7 @@ class RequestsAWSV4SignerAuth(requests.auth.AuthBase):
querystring = ""
if url.query:
querystring = "?" + urlencode(
parse_qs(url.query, keep_blank_values=True), doseq=True
parse_qs(url.query, keep_blank_values=True), doseq=True # type: ignore
)
# fetch the host information from headers
@@ -122,7 +122,7 @@ class RequestsAWSV4SignerAuth(requests.auth.AuthBase):
location = headers.get("host") or url.netloc
# construct the url and return
return url.scheme + "://" + location + path + querystring
return url.scheme + "://" + location + path + querystring # type: ignore
# Deprecated: use RequestsAWSV4SignerAuth
@@ -77,6 +77,20 @@ class TestAsyncSigner:
assert "X-Amz-Security-Token" in headers
assert "X-Amz-Content-SHA256" in headers
async def test_aws_signer_async_fetch_url_with_querystring(self) -> None:
region = "us-west-2"
service = "aoss"
from opensearchpy.helpers.asyncsigner import AWSV4SignerAsyncAuth
auth = AWSV4SignerAsyncAuth(self.mock_session(), region, service)
signature_host = auth._fetch_url(
"http://localhost/?foo=bar", headers={"host": "otherhost"}
)
assert signature_host == "http://otherhost/?foo=bar"
class TestAsyncSignerWithFrozenCredentials(TestAsyncSigner):
def mock_session(self, disable_get_frozen: bool = True) -> Mock:
@@ -143,6 +157,7 @@ class TestAsyncSignerWithSpecialCharacters:
url: str,
query_string: Optional[str] = None,
body: Optional[Union[str, bytes]] = None,
headers: Optional[Dict[str, str]] = None,
) -> Dict[str, str]:
nonlocal signed_url
signed_url = url
@@ -457,6 +457,23 @@ class TestRequestsHttpConnection(TestCase):
return dummy_session
def test_aws_signer_fetch_url_with_querystring(self) -> None:
region = "us-west-2"
import requests
from opensearchpy.helpers.signer import RequestsAWSV4SignerAuth
auth = RequestsAWSV4SignerAuth(self.mock_session(), region)
prepared_request = requests.Request(
"GET", "http://localhost/?foo=bar", headers={"host": "otherhost:443"}
).prepare()
signature_host = auth._fetch_url(prepared_request)
assert signature_host == "http://otherhost:443/?foo=bar"
def test_aws_signer_as_http_auth(self) -> None:
region = "us-west-2"