Skip to content
This repository was archived by the owner on Aug 30, 2024. It is now read-only.

Commit 1020447

Browse files
authored
Merge pull request #365 from cloudant/354-support-iam-auth-in-replications
Support IAM authentication in replication documents
2 parents 9817315 + 8cbee2a commit 1020447

File tree

6 files changed

+247
-29
lines changed

6 files changed

+247
-29
lines changed

CHANGES.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Unreleased
22

3-
- [NEW] Moved `create_query_index` and other query related methods to `CouchDatabase` as the `_index`/`_find` API is available in CouchDB 2.x.
43
- [NEW] Added functionality to test if a key is in a database as in `key in db`, overriding dict `__contains__` and checking in the remote database.
4+
- [NEW] Moved `create_query_index` and other query related methods to `CouchDatabase` as the `_index`/`_find` API is available in CouchDB 2.x.
5+
- [NEW] Support IAM authentication in replication documents.
56
- [IMPROVED] Added support for IAM API key in `cloudant_bluemix` method.
67
- [IMPROVED] Updated Travis CI and unit tests to run against CouchDB 2.1.1.
78

src/cloudant/_client_session.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env python
2-
# Copyright (c) 2015, 2017 IBM Corp. All rights reserved.
2+
# Copyright (c) 2015, 2018 IBM Corp. All rights reserved.
33
#
44
# Licensed under the Apache License, Version 2.0 (the "License");
55
# you may not use this file except in compliance with the License.
@@ -195,6 +195,15 @@ def __init__(self, api_key, server_url, **kwargs):
195195
self._token_url = os.environ.get(
196196
'IAM_TOKEN_URL', 'https://iam.bluemix.net/identity/token')
197197

198+
@property
199+
def get_api_key(self):
200+
"""
201+
Get IAM API key.
202+
203+
:return: IAM API key.
204+
"""
205+
return self._api_key
206+
198207
def login(self):
199208
"""
200209
Perform IAM cookie based user login.

src/cloudant/client.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ def __init__(self, user, auth_token, admin_party=False, **kwargs):
9898
if connect_to_couch and self._DATABASE_CLASS == CouchDatabase:
9999
self.connect()
100100

101+
@property
102+
def is_iam_authenticated(self):
103+
"""
104+
Show if a client has authenticated using an IAM API key.
105+
106+
:return: True if client is IAM authenticated. False otherwise.
107+
"""
108+
return self._use_iam
109+
101110
def connect(self):
102111
"""
103112
Starts up an authentication session for the client using cookie
@@ -107,10 +116,12 @@ def connect(self):
107116
self.session_logout()
108117

109118
if self.admin_party:
119+
self._use_iam = False
110120
self.r_session = ClientSession(
111121
timeout=self._timeout
112122
)
113123
elif self._use_basic_auth:
124+
self._use_iam = False
114125
self.r_session = BasicSession(
115126
self._user,
116127
self._auth_token,

src/cloudant/replicator.py

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -51,41 +51,58 @@ def create_replication(self, source_db=None, target_db=None,
5151
:param str repl_id: Optional replication id. Generated internally if
5252
not explicitly set.
5353
:param dict user_ctx: Optional user to act as. Composed internally
54-
if not explicitly set and not in CouchDB Admin Party
55-
mode.
54+
if not explicitly set.
5655
:param bool create_target: Specifies whether or not to
5756
create the target, if it does not already exist.
5857
:param bool continuous: If set to True then the replication will be
5958
continuous.
6059
6160
:returns: Replication document as a Document instance
6261
"""
62+
if source_db is None:
63+
raise CloudantReplicatorException(101)
64+
65+
if target_db is None:
66+
raise CloudantReplicatorException(102)
6367

6468
data = dict(
6569
_id=repl_id if repl_id else str(uuid.uuid4()),
6670
**kwargs
6771
)
6872

69-
if source_db is None:
70-
raise CloudantReplicatorException(101)
73+
# replication source
74+
7175
data['source'] = {'url': source_db.database_url}
72-
if not source_db.admin_party:
73-
data['source'].update(
74-
{'headers': {'Authorization': source_db.creds['basic_auth']}}
75-
)
76+
if source_db.admin_party:
77+
pass # no credentials required
78+
elif source_db.client.is_iam_authenticated:
79+
data['source'].update({'auth': {
80+
'iam': {'api_key': source_db.client.r_session.get_api_key}
81+
}})
82+
else:
83+
data['source'].update({'headers': {
84+
'Authorization': source_db.creds['basic_auth']
85+
}})
86+
87+
# replication target
7688

77-
if target_db is None:
78-
raise CloudantReplicatorException(102)
7989
data['target'] = {'url': target_db.database_url}
80-
if not target_db.admin_party:
81-
data['target'].update(
82-
{'headers': {'Authorization': target_db.creds['basic_auth']}}
83-
)
84-
85-
if not data.get('user_ctx'):
86-
if (target_db and not target_db.admin_party or
87-
self.database.creds):
88-
data['user_ctx'] = self.database.creds['user_ctx']
90+
if target_db.admin_party:
91+
pass # no credentials required
92+
elif target_db.client.is_iam_authenticated:
93+
data['target'].update({'auth': {
94+
'iam': {'api_key': target_db.client.r_session.get_api_key}
95+
}})
96+
else:
97+
data['target'].update({'headers': {
98+
'Authorization': target_db.creds['basic_auth']
99+
}})
100+
101+
# add user context delegation
102+
103+
if not data.get('user_ctx') and self.database.creds and \
104+
self.database.creds.get('user_ctx'):
105+
data['user_ctx'] = self.database.creds['user_ctx']
89106

90107
return self.database.create_document(data, throw_on_exists=True)
91108

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
#!/usr/bin/env python
2+
# Copyright (C) 2018 IBM Corp. All rights reserved.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""
17+
_replicator_mock_tests_
18+
19+
replicator module - Mock unit tests for the Replicator class
20+
"""
21+
22+
import mock
23+
import unittest
24+
25+
from cloudant.database import CouchDatabase
26+
from cloudant.replicator import Replicator
27+
28+
from tests.unit.iam_auth_tests import MOCK_API_KEY
29+
30+
31+
class ReplicatorDocumentValidationMockTests(unittest.TestCase):
32+
"""
33+
Replicator document validation tests
34+
"""
35+
36+
def setUp(self):
37+
self.repl_id = 'rep_test'
38+
39+
self.server_url = 'http://localhost:5984'
40+
self.user_ctx = {
41+
'name': 'foo',
42+
'roles': ['erlanger', 'researcher']
43+
}
44+
45+
self.source_db = 'source_db'
46+
self.target_db = 'target_db'
47+
48+
def setUpClientMocks(self, admin_party=False, iam_api_key=None):
49+
m_client = mock.MagicMock()
50+
type(m_client).server_url = mock.PropertyMock(
51+
return_value=self.server_url)
52+
53+
type(m_client).admin_party = mock.PropertyMock(
54+
return_value=admin_party)
55+
56+
iam_authenticated = False
57+
58+
if iam_api_key is not None:
59+
iam_authenticated = True
60+
61+
m_session = mock.MagicMock()
62+
type(m_session).get_api_key = mock.PropertyMock(
63+
return_value=iam_api_key)
64+
65+
type(m_client).r_session = mock.PropertyMock(
66+
return_value=m_session)
67+
68+
type(m_client).is_iam_authenticated = mock.PropertyMock(
69+
return_value=iam_authenticated)
70+
71+
return m_client
72+
73+
def test_using_admin_party_source_and_target(self):
74+
m_admin_party_client = self.setUpClientMocks(admin_party=True)
75+
76+
m_replicator = mock.MagicMock()
77+
type(m_replicator).creds = mock.PropertyMock(return_value=None)
78+
m_admin_party_client.__getitem__.return_value = m_replicator
79+
80+
# create source/target databases
81+
src = CouchDatabase(m_admin_party_client, self.source_db)
82+
tgt = CouchDatabase(m_admin_party_client, self.target_db)
83+
84+
# trigger replication
85+
rep = Replicator(m_admin_party_client)
86+
rep.create_replication(src, tgt, repl_id=self.repl_id)
87+
88+
kcall = m_replicator.create_document.call_args_list
89+
self.assertEquals(len(kcall), 1)
90+
args, kwargs = kcall[0]
91+
self.assertEquals(len(args), 1)
92+
93+
expected_doc = {
94+
'_id': self.repl_id,
95+
'source': {'url': '/'.join((self.server_url, self.source_db))},
96+
'target': {'url': '/'.join((self.server_url, self.target_db))}
97+
}
98+
99+
self.assertDictEqual(args[0], expected_doc)
100+
self.assertTrue(kwargs['throw_on_exists'])
101+
102+
def test_using_basic_auth_source_and_target(self):
103+
test_basic_auth_header = 'abc'
104+
105+
m_basic_auth_client = self.setUpClientMocks()
106+
107+
m_replicator = mock.MagicMock()
108+
m_basic_auth_client.__getitem__.return_value = m_replicator
109+
m_basic_auth_client.basic_auth_str.return_value = test_basic_auth_header
110+
111+
# create source/target databases
112+
src = CouchDatabase(m_basic_auth_client, self.source_db)
113+
tgt = CouchDatabase(m_basic_auth_client, self.target_db)
114+
115+
# trigger replication
116+
rep = Replicator(m_basic_auth_client)
117+
rep.create_replication(
118+
src, tgt, repl_id=self.repl_id, user_ctx=self.user_ctx)
119+
120+
kcall = m_replicator.create_document.call_args_list
121+
self.assertEquals(len(kcall), 1)
122+
args, kwargs = kcall[0]
123+
self.assertEquals(len(args), 1)
124+
125+
expected_doc = {
126+
'_id': self.repl_id,
127+
'user_ctx': self.user_ctx,
128+
'source': {
129+
'headers': {'Authorization': test_basic_auth_header},
130+
'url': '/'.join((self.server_url, self.source_db))
131+
},
132+
'target': {
133+
'headers': {'Authorization': test_basic_auth_header},
134+
'url': '/'.join((self.server_url, self.target_db))
135+
}
136+
}
137+
138+
self.assertDictEqual(args[0], expected_doc)
139+
self.assertTrue(kwargs['throw_on_exists'])
140+
141+
def test_using_iam_auth_source_and_target(self):
142+
m_iam_auth_client = self.setUpClientMocks(iam_api_key=MOCK_API_KEY)
143+
144+
m_replicator = mock.MagicMock()
145+
m_iam_auth_client.__getitem__.return_value = m_replicator
146+
147+
# create source/target databases
148+
src = CouchDatabase(m_iam_auth_client, self.source_db)
149+
tgt = CouchDatabase(m_iam_auth_client, self.target_db)
150+
151+
# trigger replication
152+
rep = Replicator(m_iam_auth_client)
153+
rep.create_replication(
154+
src, tgt, repl_id=self.repl_id, user_ctx=self.user_ctx)
155+
156+
kcall = m_replicator.create_document.call_args_list
157+
self.assertEquals(len(kcall), 1)
158+
args, kwargs = kcall[0]
159+
self.assertEquals(len(args), 1)
160+
161+
expected_doc = {
162+
'_id': self.repl_id,
163+
'user_ctx': self.user_ctx,
164+
'source': {
165+
'auth': {'iam': {'api_key': MOCK_API_KEY}},
166+
'url': '/'.join((self.server_url, self.source_db))
167+
},
168+
'target': {
169+
'auth': {'iam': {'api_key': MOCK_API_KEY}},
170+
'url': '/'.join((self.server_url, self.target_db))
171+
}
172+
}
173+
174+
self.assertDictEqual(args[0], expected_doc)
175+
self.assertTrue(kwargs['throw_on_exists'])

tests/unit/replicator_tests.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env python
2-
# Copyright (c) 2015, 2016, 2017 IBM Corp. All rights reserved.
2+
# Copyright (c) 2015, 2018 IBM Corp. All rights reserved.
33
#
44
# Licensed under the Apache License, Version 2.0 (the "License");
55
# you may not use this file except in compliance with the License.
@@ -161,7 +161,6 @@ def test_replication_with_generated_id(self):
161161
)
162162
self.replication_ids.append(repl_id['_id'])
163163

164-
@skip_if_not_cookie_auth
165164
@flaky(max_runs=3)
166165
def test_create_replication(self):
167166
"""
@@ -180,7 +179,7 @@ def test_create_replication(self):
180179
# Test that the replication document was created
181180
expected_keys = ['_id', '_rev', 'source', 'target', 'user_ctx']
182181
# If Admin Party mode then user_ctx will not be in the key list
183-
if self.client.admin_party:
182+
if self.client.admin_party or self.client.is_iam_authenticated:
184183
expected_keys.pop()
185184
self.assertTrue(all(x in list(repl_doc.keys()) for x in expected_keys))
186185
self.assertEqual(repl_doc['_id'], repl_id)
@@ -238,7 +237,7 @@ def test_timeout_in_create_replication(self):
238237
# Test that the replication document was created
239238
expected_keys = ['_id', '_rev', 'source', 'target', 'user_ctx']
240239
# If Admin Party mode then user_ctx will not be in the key list
241-
if self.client.admin_party:
240+
if self.client.admin_party or self.client.is_iam_authenticated:
242241
expected_keys.pop()
243242
self.assertTrue(all(x in list(repl_doc.keys()) for x in expected_keys))
244243
self.assertEqual(repl_doc['_id'], repl_id)
@@ -305,7 +304,6 @@ def test_list_replications(self):
305304
match = [repl_id for repl_id in all_repl_ids if repl_id in repl_ids]
306305
self.assertEqual(set(repl_ids), set(match))
307306

308-
@skip_if_not_cookie_auth
309307
def test_retrieve_replication_state(self):
310308
"""
311309
Test that the replication state can be retrieved for a replication
@@ -347,7 +345,6 @@ def test_retrieve_replication_state_using_invalid_id(self):
347345
)
348346
self.assertIsNone(repl_state)
349347

350-
@skip_if_not_cookie_auth
351348
def test_stop_replication(self):
352349
"""
353350
Test that a replication can be stopped.
@@ -359,7 +356,16 @@ def test_stop_replication(self):
359356
self.target_db,
360357
repl_id
361358
)
362-
self.replicator.stop_replication(repl_id)
359+
max_retry = 3
360+
while True:
361+
try:
362+
max_retry -= 1
363+
self.replicator.stop_replication(repl_id)
364+
break
365+
except requests.HTTPError as err:
366+
self.assertEqual(err.response.status_code, 409)
367+
if max_retry == 0:
368+
self.fail('Failed to stop replication: {0}'.format(err))
363369
try:
364370
# The .fetch() will fail since the replication has been stopped
365371
# and the replication document has been removed from the db.
@@ -383,7 +389,6 @@ def test_stop_replication_using_invalid_id(self):
383389
'Replication with id {} not found.'.format(repl_id)
384390
)
385391

386-
@skip_if_not_cookie_auth
387392
def test_follow_replication(self):
388393
"""
389394
Test that follow_replication(...) properly iterates updated

0 commit comments

Comments
 (0)