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

Commit cb6998c

Browse files
committed
Support IAM authentication in replication documents
1 parent 9817315 commit cb6998c

File tree

6 files changed

+235
-23
lines changed

6 files changed

+235
-23
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: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -60,31 +60,51 @@ def create_replication(self, source_db=None, target_db=None,
6060
6161
:returns: Replication document as a Document instance
6262
"""
63+
if source_db is None:
64+
raise CloudantReplicatorException(101)
65+
66+
if target_db is None:
67+
raise CloudantReplicatorException(102)
6368

6469
data = dict(
6570
_id=repl_id if repl_id else str(uuid.uuid4()),
6671
**kwargs
6772
)
6873

69-
if source_db is None:
70-
raise CloudantReplicatorException(101)
74+
# replication source
75+
7176
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-
)
77+
if source_db.admin_party:
78+
pass # no credentials required
79+
elif source_db.client.is_iam_authenticated:
80+
data['source'].update({'auth': {
81+
'iam': {'api_key': source_db.client.r_session.get_api_key}
82+
}})
83+
else:
84+
data['source'].update({'headers': {
85+
'Authorization': source_db.creds['basic_auth']
86+
}})
87+
88+
# replication target
7689

77-
if target_db is None:
78-
raise CloudantReplicatorException(102)
7990
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-
)
91+
if target_db.admin_party:
92+
pass # no credentials required
93+
elif target_db.client.is_iam_authenticated:
94+
data['target'].update({'auth': {
95+
'iam': {'api_key': target_db.client.r_session.get_api_key}
96+
}})
97+
else:
98+
data['target'].update({'headers': {
99+
'Authorization': target_db.creds['basic_auth']
100+
}})
101+
102+
# add user context delegation
84103

85104
if not data.get('user_ctx'):
86-
if (target_db and not target_db.admin_party or
87-
self.database.creds):
105+
if target_db and target_db.admin_party:
106+
pass # noop - not required for admin party mode
107+
elif self.database.creds and self.database.creds.get('user_ctx'):
88108
data['user_ctx'] = self.database.creds['user_ctx']
89109

90110
return self.database.create_document(data, throw_on_exists=True)
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: 3 additions & 7 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.
@@ -383,7 +380,6 @@ def test_stop_replication_using_invalid_id(self):
383380
'Replication with id {} not found.'.format(repl_id)
384381
)
385382

386-
@skip_if_not_cookie_auth
387383
def test_follow_replication(self):
388384
"""
389385
Test that follow_replication(...) properly iterates updated

0 commit comments

Comments
 (0)