Skip to content

Commit 6ab90be

Browse files
committed
adds IAM auth support
Signed-off-by: Shivam Dhar <dhshivam@amazon.com>
1 parent 65c12d7 commit 6ab90be

File tree

9 files changed

+157
-1
lines changed

9 files changed

+157
-1
lines changed

opensearchpy/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
SSLError,
6262
TransportError,
6363
)
64+
from .helpers import AWSV4SignerAuth
6465
from .serializer import JSONSerializer
6566
from .transport import Transport
6667

@@ -92,6 +93,7 @@
9293
"AuthorizationException",
9394
"OpenSearchWarning",
9495
"OpenSearchDeprecationWarning",
96+
"AWSV4SignerAuth",
9597
]
9698

9799
try:

opensearchpy/__init__.pyi

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ try:
5757
from ._async.client import AsyncOpenSearch as AsyncOpenSearch
5858
from ._async.http_aiohttp import AIOHttpConnection as AIOHttpConnection
5959
from ._async.transport import AsyncTransport as AsyncTransport
60+
from .helpers import AWSV4SignerAuth as AWSV4SignerAuth
6061
except (ImportError, SyntaxError):
6162
pass
6263

opensearchpy/connection_pool.pyi

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, Union
3030
from .connection import Connection
3131

3232
try:
33-
from Queue import PriorityQueue # type: ignore
33+
from Queue import PriorityQueue
3434
except ImportError:
3535
from queue import PriorityQueue
3636

opensearchpy/helpers/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
streaming_bulk,
3838
)
3939
from .errors import BulkIndexError, ScanError
40+
from .signer import AWSV4SignerAuth
4041

4142
__all__ = [
4243
"BulkIndexError",
@@ -49,6 +50,7 @@
4950
"reindex",
5051
"_chunk_actions",
5152
"_process_bulk_chunk",
53+
"AWSV4SignerAuth",
5254
]
5355

5456

opensearchpy/helpers/__init__.pyi

+1
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,6 @@ try:
4646
from .._async.helpers import async_reindex as async_reindex
4747
from .._async.helpers import async_scan as async_scan
4848
from .._async.helpers import async_streaming_bulk as async_streaming_bulk
49+
from .signer import AWSV4SignerAuth as AWSV4SignerAuth
4950
except (ImportError, SyntaxError):
5051
pass

opensearchpy/helpers/signer.py

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
#
3+
# The OpenSearch Contributors require contributions made to
4+
# this file be licensed under the Apache-2.0 license or a
5+
# compatible open source license.
6+
#
7+
# Modifications Copyright OpenSearch Contributors. See
8+
# GitHub history for details.
9+
10+
import sys
11+
12+
import requests
13+
14+
OPENSEARCH_SERVICE = "es"
15+
16+
PY3 = sys.version_info[0] == 3
17+
18+
if PY3:
19+
from urllib.parse import parse_qs, urlencode, urlparse
20+
21+
22+
def fetch_url(prepared_request): # type: ignore
23+
"""
24+
This is a util method that helps in reconstructing the request url.
25+
:param prepared_request: unsigned request
26+
:return: reconstructed url
27+
"""
28+
url = urlparse(prepared_request.url)
29+
path = url.path or "/"
30+
31+
# fetch the query string if present in the request
32+
querystring = ""
33+
if url.query:
34+
querystring = "?" + urlencode(
35+
parse_qs(url.query, keep_blank_values=True), doseq=True
36+
)
37+
38+
# fetch the host information from headers
39+
headers = dict(
40+
(key.lower(), value) for key, value in prepared_request.headers.items()
41+
)
42+
location = headers.get("host") or url.netloc
43+
44+
# construct the url and return
45+
return url.scheme + "://" + location + path + querystring
46+
47+
48+
class AWSV4SignerAuth(requests.auth.AuthBase):
49+
"""
50+
AWS V4 Request Signer for Requests.
51+
"""
52+
53+
def __init__(self, credentials, region): # type: ignore
54+
if not credentials:
55+
raise ValueError("Credentials cannot be empty")
56+
self.credentials = credentials
57+
58+
if not region:
59+
raise ValueError("Region cannot be empty")
60+
self.region = region
61+
62+
def __call__(self, request): # type: ignore
63+
return self._sign_request(request) # type: ignore
64+
65+
def _sign_request(self, prepared_request): # type: ignore
66+
"""
67+
This method helps in signing the request by injecting the required headers.
68+
:param prepared_request: unsigned request
69+
:return: signed request
70+
"""
71+
72+
from botocore.auth import SigV4Auth
73+
from botocore.awsrequest import AWSRequest
74+
75+
url = fetch_url(prepared_request) # type: ignore
76+
77+
# create an AWS request object and sign it using SigV4Auth
78+
aws_request = AWSRequest(
79+
method=prepared_request.method.upper(),
80+
url=url,
81+
data=prepared_request.body,
82+
)
83+
sig_v4_auth = SigV4Auth(self.credentials, OPENSEARCH_SERVICE, self.region)
84+
sig_v4_auth.add_auth(aws_request)
85+
86+
# copy the headers from AWS request object into the prepared_request
87+
prepared_request.headers.update(dict(aws_request.headers.items()))
88+
89+
return prepared_request

setup.cfg

+3
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,6 @@ junit_family=legacy
1212

1313
[tool:isort]
1414
profile=black
15+
16+
[mypy]
17+
ignore_missing_imports=True

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"pyyaml",
6060
"pytest",
6161
"pytest-cov",
62+
"botocore;python_version>='3.6'",
6263
]
6364
async_require = ["aiohttp>=3,<4"]
6465

test_opensearchpy/test_connection.py

+57
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
import os
3232
import re
3333
import ssl
34+
import sys
3435
import unittest
36+
import uuid
3537
import warnings
3638
from platform import python_version
3739

@@ -284,6 +286,61 @@ def test_http_auth_list(self):
284286
con.headers,
285287
)
286288

289+
@pytest.mark.skipif(
290+
sys.version_info < (3, 6), reason="AWSV4SignerAuth requires python3.6+"
291+
)
292+
def test_aws_signer_as_http_auth(self):
293+
region = "us-west-2"
294+
295+
import requests
296+
297+
from opensearchpy.helpers.signer import AWSV4SignerAuth
298+
299+
auth = AWSV4SignerAuth(self.mock_session(), region)
300+
con = RequestsHttpConnection(http_auth=auth)
301+
prepared_request = requests.Request("GET", "http://localhost").prepare()
302+
auth(prepared_request)
303+
self.assertEqual(auth, con.session.auth)
304+
self.assertIn("Authorization", prepared_request.headers)
305+
self.assertIn("X-Amz-Date", prepared_request.headers)
306+
self.assertIn("X-Amz-Security-Token", prepared_request.headers)
307+
308+
def test_aws_signer_when_region_is_null(self):
309+
session = self.mock_session()
310+
311+
from opensearchpy.helpers.signer import AWSV4SignerAuth
312+
313+
with pytest.raises(ValueError) as e:
314+
AWSV4SignerAuth(session, None)
315+
assert str(e.value) == "Region cannot be empty"
316+
317+
with pytest.raises(ValueError) as e:
318+
AWSV4SignerAuth(session, "")
319+
assert str(e.value) == "Region cannot be empty"
320+
321+
def test_aws_signer_when_credentials_is_null(self):
322+
region = "us-west-1"
323+
324+
from opensearchpy.helpers.signer import AWSV4SignerAuth
325+
326+
with pytest.raises(ValueError) as e:
327+
AWSV4SignerAuth(None, region)
328+
assert str(e.value) == "Credentials cannot be empty"
329+
330+
with pytest.raises(ValueError) as e:
331+
AWSV4SignerAuth("", region)
332+
assert str(e.value) == "Credentials cannot be empty"
333+
334+
def mock_session(self):
335+
access_key = uuid.uuid4().hex
336+
secret_key = uuid.uuid4().hex
337+
token = uuid.uuid4().hex
338+
dummy_session = Mock()
339+
dummy_session.access_key = access_key
340+
dummy_session.secret_key = secret_key
341+
dummy_session.token = token
342+
return dummy_session
343+
287344
def test_uses_https_if_verify_certs_is_off(self):
288345
with warnings.catch_warnings(record=True) as w:
289346
con = Urllib3HttpConnection(use_ssl=True, verify_certs=False)

0 commit comments

Comments
 (0)