Skip to content

Commit b4afa16

Browse files
authored
Enable support for application credentials (#39)
* Avoid using AsyncMagicMock in python >=3.8 * Add support for application credentials
1 parent e4307e9 commit b4afa16

File tree

3 files changed

+112
-38
lines changed

3 files changed

+112
-38
lines changed

README.rst

+10-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Usage
3535
from asyncopenstackclient import NovaClient, GlanceClient, CinderClient, AuthPassword
3636
3737
# you can either pass credentials explicitly (as shown below)
38-
# or use enviormental variables from OpenStack RC file
38+
# or use environmental variables from OpenStack RC file
3939
# https://docs.openstack.org/mitaka/cli-reference/common/cli_set_environment_variables_using_openstack_rc.html
4040
auth = AuthPassword(
4141
auth_url='https://keystone:5999/v3'
@@ -44,6 +44,15 @@ Usage
4444
user_domain_name='default',
4545
project_domain_name='foo.bar'
4646
)
47+
48+
# alternatively you can also use application_credentials to authenticate with the OpenStack Keystone API
49+
# https://docs.openstack.org/keystone/queens/user/application_credentials.html
50+
alternative_auth = AuthPassword(
51+
auth_url='https://keystone:5999/v3'
52+
application_credential_id="ID",
53+
application_credential_secret="SECRET"
54+
)
55+
4756
nova = NovaClient(session=auth)
4857
glance = GlanceClient(session=auth)
4958
cinder = CinderClient(session=auth)

src/asyncopenstackclient/auth.py

+58-24
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ def __init__(self):
1515
self._project_id = None
1616
self._project_name = None
1717
self._region_name = None
18+
self._application_credential_id = None
19+
self._application_credential_secret = None
1820

1921
async def authenticate(self):
2022
raise NotImplementedError
@@ -51,51 +53,83 @@ def os_project_name(self):
5153
def os_region_name(self):
5254
return self._region_name or os.environ.get('OS_REGION_NAME')
5355

56+
@property
57+
def os_application_credential_id(self):
58+
return self._application_credential_id or os.environ.get('OS_APPLICATION_CREDENTIAL_ID')
59+
60+
@property
61+
def os_application_credential_secret(self):
62+
return self._application_credential_secret or os.environ.get('OS_APPLICATION_CREDENTIAL_SECRET')
63+
5464

5565
class AuthPassword(AuthModel):
5666

57-
def __init__(self, auth_url=None, username=None, password=None, project_name=None, user_domain_name=None, project_domain_name=None):
67+
def __init__(self, auth_url=None, username=None, password=None, project_name=None,
68+
user_domain_name=None, project_domain_name=None,
69+
application_credential_id=None, application_credential_secret=None):
5870
super().__init__()
5971
self._auth_url = auth_url
6072
self._username = username
6173
self._password = password
6274
self._project_name = project_name
6375
self._user_domain_name = user_domain_name
6476
self._project_domain_name = project_domain_name
77+
self._application_credential_id = application_credential_id
78+
self._application_credential_secret = application_credential_secret
6579

6680
self._auth_endpoint = self.os_auth_url + '/auth/tokens'
6781
self.token = None
6882
self.token_expires_at = 0
6983
self.headers = {
7084
'Content-Type': 'application/json'
7185
}
72-
self._auth_payload = {
73-
'auth': {
74-
'identity': {
75-
'methods': ['password'],
76-
'password': {
77-
'user': {
78-
'domain': {
79-
'name': self.os_user_domain_name
80-
},
81-
'name': self.os_username,
82-
'password': self.os_password
83-
}
84-
}
85-
},
86-
'scope': {
87-
"project": {
88-
"domain": {
89-
"name": self.os_project_domain_name
86+
87+
self._auth_payload = {'auth': {}}
88+
for key_from_property in (self._identity, self._scope):
89+
self._auth_payload['auth'].update(key_from_property)
90+
91+
def is_token_valid(self):
92+
return self.token_expires_at - time() > 0
93+
94+
@property
95+
def _identity(self):
96+
if self.os_application_credential_id and self.os_application_credential_secret:
97+
return {"identity": {
98+
"methods": ["application_credential"],
99+
"application_credential": {
100+
"id": self.os_application_credential_id,
101+
"secret": self.os_application_credential_secret
102+
}
103+
}
104+
}
105+
else:
106+
return {"identity": {
107+
'methods': ['password'],
108+
'password': {
109+
'user': {
110+
'domain': {
111+
'name': self.os_user_domain_name
90112
},
91-
"name": self.os_project_name
113+
'name': self.os_username,
114+
'password': self.os_password
92115
}
93116
}
94-
}
95-
}
117+
}}
96118

97-
def is_token_valid(self):
98-
return self.token_expires_at - time() > 0
119+
@property
120+
def _scope(self):
121+
if self.os_application_credential_id and self.os_application_credential_secret:
122+
# The scope is automatically determined from the application_credential
123+
return {}
124+
else:
125+
return {"scope": {
126+
"project": {
127+
"domain": {
128+
"name": self.os_project_domain_name
129+
},
130+
"name": self.os_project_name
131+
}
132+
}}
99133

100134
async def get_token(self):
101135
async with aiohttp.ClientSession() as session:

tests/test_auth.py

+44-13
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from aioresponses import aioresponses
33
from aiounittest import AsyncTestCase, futurized
44
from asyncopenstackclient import AuthPassword
5-
from unittest.mock import patch
5+
from unittest.mock import MagicMock, patch
66

77

88
class TestAuth(AsyncTestCase):
@@ -19,33 +19,63 @@ def tearDown(self):
1919
del os.environ[name]
2020

2121
async def test_create_object(self):
22-
expected_payload = {'auth': {
22+
expected_payload_password = {'auth': {
2323
'identity': {'methods': ['password'], 'password': {'user': {
2424
'domain': {'name': 'm_user_domain'},
2525
'name': 'm_user', 'password': 'm_pass'
2626
}}},
2727
'scope': {'project': {'domain': {'name': 'm_project_domain'}, 'name': 'm_project'}}
2828
}}
29-
self.assertEqual(self.auth._auth_payload, expected_payload)
30-
self.assertEqual(self.auth._auth_endpoint, 'http://url/auth/tokens')
31-
self.assertTrue('Content-Type' in self.auth.headers)
29+
30+
expected_payload_application_credential = {'auth': {
31+
'identity': {'methods': ['application_credential'],
32+
'application_credential': {'id': 'm_app_id',
33+
'secret': 'm_app_secret'}}
34+
}}
35+
36+
auth_args_application_credentials = ('http://url', None, None, None, None,
37+
None, 'm_app_id', 'm_app_secret')
38+
39+
auth_application_credentials = AuthPassword(*auth_args_application_credentials)
40+
41+
for auth, expected_payload in ((self.auth, expected_payload_password),
42+
(auth_application_credentials, expected_payload_application_credential)):
43+
self.assertEqual(auth._auth_payload, expected_payload)
44+
self.assertEqual(auth._auth_endpoint, 'http://url/auth/tokens')
45+
self.assertTrue('Content-Type' in auth.headers)
3246

3347
async def test_create_object_use_environ(self):
34-
expected_payload = {'auth': {
48+
expected_payload_password = {'auth': {
3549
'identity': {'methods': ['password'], 'password': {'user': {'domain': {'name': 'udm'}, 'name': 'uuu', 'password': 'ppp'}}},
3650
'scope': {'project': {'domain': {'name': 'udm'}, 'name': 'prj'}}
3751
}}
38-
env = {
52+
env_password = {
3953
'OS_AUTH_URL': 'https://keystone/v3',
4054
'OS_PASSWORD': 'ppp', 'OS_USERNAME': 'uuu',
4155
'OS_USER_DOMAIN_NAME': 'udm', 'OS_PROJECT_NAME': 'prj'
4256
}
4357

44-
with patch.dict('os.environ', env, clear=True):
45-
auth = AuthPassword()
46-
self.assertEqual(auth._auth_payload, expected_payload)
47-
self.assertEqual(auth._auth_endpoint, 'https://keystone/v3/auth/tokens')
48-
self.assertTrue('Content-Type' in auth.headers)
58+
expected_payload_application_credentials = {'auth': {
59+
'identity': {'methods': ['application_credential'],
60+
'application_credential': {'id': 'iid',
61+
'secret': 'ssecret'
62+
}
63+
}
64+
}}
65+
env_application_credentials = {
66+
'OS_AUTH_URL': 'https://keystone/v3',
67+
'OS_APPLICATION_CREDENTIAL_ID': 'iid',
68+
'OS_APPLICATION_CREDENTIAL_SECRET': 'ssecret',
69+
'OS_USER_DOMAIN_NAME': 'udm', 'OS_PROJECT_NAME': 'prj'
70+
}
71+
72+
for env, expected_payload in ((env_password, expected_payload_password),
73+
(env_application_credentials, expected_payload_application_credentials)):
74+
with patch.dict('os.environ', env, clear=True):
75+
auth = AuthPassword()
76+
self.assertEqual(auth._auth_payload, expected_payload)
77+
self.assertEqual(auth._auth_endpoint, 'https://keystone/v3/auth/tokens')
78+
self.assertTrue('Content-Type' in auth.headers)
4979

5080
async def test_get_token(self):
5181
body = {
@@ -107,7 +137,8 @@ async def test_authenticate_first_time(self):
107137
1100
108138
]
109139

110-
patch('asyncopenstackclient.auth.AuthPassword.get_token', side_effect=mock_get_token_results).start()
140+
get_token_mock = patch('asyncopenstackclient.auth.AuthPassword.get_token', new=MagicMock()).start()
141+
get_token_mock.side_effect = mock_get_token_results
111142
patch('asyncopenstackclient.auth.time', side_effect=mock_time_results).start()
112143

113144
# first time token should be None and get_token shall be called

0 commit comments

Comments
 (0)