Skip to content

Commit d214aa3

Browse files
authored
Release/v1.1 (#11)
* Feature/user sessions (#10) * Fixed file opening with context handler to ensure that files are not already closed when actually posting. * Added SimpleTenantApp sample. * Added User Sessions sample. * Split CumulocityApp to SimpleCumulocityApp and MultiTenantCumulocityApp; Adding proper caching with TTL; Adding support for Token-based authentication; Adding support for user sessions; Adding samples. * Fixed typo. * Prepare release 1.1
1 parent e6cd0b6 commit d214aa3

File tree

16 files changed

+986
-265
lines changed

16 files changed

+986
-265
lines changed

CHANGELOG.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,47 @@
33

44
## Work in progress
55

6+
## Version 1.1
7+
8+
### Notes
9+
10+
* _Warning_, this release is a breaking change as it introduces an `auth` parameter to the API base classes,
11+
`CumulocityRestAPI` and `CumulocityAPI`. This parameter should be the new standard to use (instead of just
12+
username and password).
13+
14+
* _Warning_, this release replaces the 'all-purpose' class `CumulocityApp` with specialized versions for multi-tenant
15+
(`MultiTenantCumulocityApp`) and single tenant (`SimpleCumulocityApp`) environments.
16+
17+
### Added
18+
19+
* Added `_util.py` file to hold all cross-class auxiliary functionality.
20+
21+
* Added `_auth.py` file to hold all cross-class authentication functionality. Moved corresponding code from file
22+
`app.__init__.py` to the `AuthUtil` class.
23+
24+
* Added `_jwt.py` with `JWT` class which encapsulates JWT handling for the libraries purpose. This is _not_ a full
25+
JWT implementation.
26+
27+
* Added `HTMLBearerAuth` class which encapsulates Cumulocity's JWT token-based authentication mechanism.
28+
29+
* Added token-based authentication support. All API classes now can be initialized with an AuthBase parameter which
30+
would allow all kinds of authentication mechanisms. As of now, `HTTPbasicAuth` and `HTTPBearerAuth` is supported.
31+
32+
* Added caching with TTL/Max Size strategies to `MultiTenantCumulocityApp` and `SimpleCumulocityApp`.
33+
34+
* Added samples: `user_sessions.py` illustrating how user sessions can be obtained and `simple_tenant_app.py`
35+
illustrating how the `SimpleCumulocityApp` class is used.
36+
37+
* Added requirements: `cachetools` (for caching), `inputtimeout` and `flask` (for samples).
38+
39+
### Changed
40+
41+
* Fixed file opening in `post_file` function in `_base_api.py` to avoid files already being closed when posting.
42+
43+
* Removed class `CumulocityApp` as it was too generic and hard to use. Replaces with classes `SimpleCumulocityApp`
44+
which behaves pretty much identical and `MultiTenantCumulocityApp` which behances more like a factory.
45+
46+
647
## Version 1.0.2
748

849
### Changed

c8y_api/_auth.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# Copyright (c) 2020 Software AG,
2+
# Darmstadt, Germany and/or Software AG USA Inc., Reston, VA, USA,
3+
# and/or its subsidiaries and/or its affiliates and/or their licensors.
4+
# Use, reproduction, transfer, publication or disclosure is prohibited except
5+
# as specifically provided for in your License Agreement with Software AG.
6+
7+
from __future__ import annotations
8+
9+
import base64
10+
from typing import Any
11+
12+
from requests.auth import HTTPBasicAuth, AuthBase
13+
14+
from c8y_api._jwt import JWT
15+
16+
17+
class HTTPBearerAuth(AuthBase):
18+
"""Token based authentication."""
19+
20+
def __init__(self, token: str):
21+
self.token = token
22+
23+
def __call__(self, r):
24+
r.headers['Authorization'] = 'Bearer ' + self.token
25+
26+
27+
class AuthUtil:
28+
"""Authorization utility functions."""
29+
30+
@staticmethod
31+
def parse_auth_string(auth_string: str) -> AuthBase:
32+
"""Parse a given auth string into a corresponding auth object.
33+
34+
Args:
35+
auth_string (str): Complete Auth string (including the type prefix
36+
like BASIC etc.) as it comes with an Authorization HTTP header
37+
38+
Returns:
39+
An AuthBase instance for this auth string.
40+
"""
41+
return AuthUtil._parse_with(auth_string,
42+
basic_fun=AuthUtil.parse_basic_auth_value,
43+
bearer_fun=AuthUtil.parse_bearer_auth_value)
44+
45+
@staticmethod
46+
def get_tenant_id(auth: AuthBase) -> str:
47+
"""Read the tenant ID from authorization information.
48+
49+
Args:
50+
auth (AuthBase): Auth instance, only HTTPBasicAuth and
51+
HTTPBearerAuth are supported
52+
53+
Returns:
54+
The tenant ID encoded in the auth information.
55+
56+
Raises:
57+
ValueError if the tenant ID cannot be resolved or an unsupported
58+
AuthBase instance was provided.
59+
"""
60+
def resolve_basic(a):
61+
username = a.username
62+
if '/' not in username:
63+
raise ValueError(f"Unable to isolate tenant ID from username: {username}")
64+
return username[:username.index('/')]
65+
66+
def resolve_bearer(a):
67+
try:
68+
tenant_id = JWT(a.token).tenant_id
69+
except KeyError:
70+
tenant_id = None
71+
if not tenant_id:
72+
raise ValueError("Unable to resolve tenant ID. JWT does not appear to include it.")
73+
return tenant_id
74+
75+
return AuthUtil._parse_auth_with(auth, resolve_basic, resolve_bearer)
76+
77+
@staticmethod
78+
def get_username(auth: AuthBase) -> str:
79+
"""Read the username from authorization information.
80+
81+
Args:
82+
auth (AuthBase): Auth instance, only HTTPBasicAuth and
83+
HTTPBearerAuth are supported
84+
85+
Returns:
86+
The username encoded in the auth information.
87+
88+
Raises:
89+
ValueError if the username cannot be resolved or an unsupported
90+
AuthBase instance was provided.
91+
"""
92+
def resolve_basic(a):
93+
return a.username
94+
95+
def resolve_bearer(a):
96+
return JWT(a.token).username
97+
98+
return AuthUtil._parse_auth_with(auth, resolve_basic, resolve_bearer)
99+
100+
@staticmethod
101+
def parse_basic_auth_value(auth_value: str) -> HTTPBasicAuth:
102+
"""Parse a BASIC HTTP auth string.
103+
104+
Args:
105+
auth_value: The Authorization header value (Base64 encoded,
106+
without the 'BASIC' type prefix)
107+
108+
Returns:
109+
An HTTPBasicAuth object.
110+
"""
111+
decoded = base64.b64decode(bytes(auth_value, 'utf-8'))
112+
parts = [x.decode('utf-8') for x in decoded.split(b':', 1)]
113+
return HTTPBasicAuth(username=parts[0], password=parts[1])
114+
115+
@staticmethod
116+
def parse_bearer_auth_value(auth_value: str) -> HTTPBearerAuth:
117+
"""Parse a BEARER HTTP auth string.
118+
119+
Args:
120+
auth_value: The Authorization header value (Base64 encoded,
121+
without the 'BEARER' type prefix)
122+
123+
Returns:
124+
An HTTPBearerAuth object.
125+
"""
126+
return HTTPBearerAuth(token=auth_value)
127+
128+
@staticmethod
129+
def _parse_auth_with(auth: AuthBase, basic_fun, bearer_fun) -> Any:
130+
"""Parse an auth instance.
131+
132+
Args:
133+
auth (AuthBase): Authentication information
134+
basic_fun: Parsing function to be applied for BASIC auth
135+
bearer_fun: Parsing function to be applied for BEARER auth
136+
137+
Returns:
138+
Whatever is returned by the parsing functions.
139+
140+
Raises:
141+
ValueError if the auth string is of an unsupported type.
142+
"""
143+
if isinstance(auth, HTTPBasicAuth):
144+
return basic_fun(auth)
145+
if isinstance(auth, HTTPBearerAuth):
146+
return bearer_fun(auth)
147+
raise ValueError(f"Unable to parse authentication information! Unexpected AuthBase instance: {auth.__class__}")
148+
149+
@staticmethod
150+
def _parse_with(auth_string: str, basic_fun, bearer_fun) -> Any:
151+
"""Parse an auth header string.
152+
153+
Args:
154+
auth_string (str): Complete auth string (including type prefix).
155+
basic_fun: Parsing function to be applied for BASIC auth
156+
bearer_fun: Parsing function to be applied for BEARER auth
157+
158+
Returns:
159+
Whatever is returned by the parsing functions.
160+
161+
Raises:
162+
ValueError if the auth string is of an unsupported type.
163+
"""
164+
auth_type, auth_value = auth_string.split(' ')
165+
166+
if auth_type.upper() == 'BASIC':
167+
return basic_fun(auth_value)
168+
if auth_type.upper() == 'BEARER':
169+
return bearer_fun(auth_value)
170+
171+
raise ValueError(f"Unexpected authorization header type: {auth_type}")

c8y_api/_base_api.py

Lines changed: 52 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,14 @@
77
from __future__ import annotations
88

99
import json as json_lib
10-
import os
11-
from typing import Union, Dict, BinaryIO, Set
10+
from typing import Union, Dict, BinaryIO
1211

13-
import requests
1412
import collections
13+
import requests
14+
from requests.auth import AuthBase, HTTPBasicAuth
1515

16-
17-
def c8y_keys() -> Set[str]:
18-
"""Provide the names of defined Cumulocity environment variables.
19-
20-
Returns: A set of environment variable names, starting with 'C8Y_'
21-
"""
22-
return set(filter(lambda x: 'C8Y_' in x, os.environ.keys()))
16+
from c8y_api._auth import HTTPBearerAuth
17+
from c8y_api._jwt import JWT
2318

2419

2520
class CumulocityRestApi:
@@ -36,36 +31,46 @@ class CumulocityRestApi:
3631
ACCEPT_GLOBAL_ROLE = 'application/vnd.com.nsn.cumulocity.group+json'
3732
CONTENT_MEASUREMENT_COLLECTION = 'application/vnd.com.nsn.cumulocity.measurementcollection+json'
3833

39-
def __init__(self, base_url: str, tenant_id: str, username: str, password: str,
40-
tfa_token: str = None, application_key: str = None):
34+
def __init__(self, base_url: str, tenant_id: str, username: str = None, password: str = None, tfa_token: str = None,
35+
auth: AuthBase = None, application_key: str = None):
4136
"""Build a CumulocityRestApi instance.
4237
38+
One of `auth` or `username/password` must be provided. The TFA token
39+
parameter is only sensible for basic authentication.
40+
4341
Args:
4442
base_url (str): Cumulocity base URL, e.g. https://cumulocity.com
4543
tenant_id (str): The ID of the tenant to connect to
4644
username (str): Username
4745
password (str): User password
4846
tfa_token (str): Currently valid two factor authorization token
47+
auth (AuthBase): Authentication details
4948
application_key (str): Application ID to include in requests
5049
(for billing/metering purposes).
5150
"""
5251
self.base_url = base_url
5352
self.tenant_id = tenant_id
54-
self.username = username
55-
self.password = password
56-
self.tfa_token = tfa_token
5753
self.application_key = application_key
58-
self.__auth = f'{tenant_id}/{username}', password
54+
55+
if auth:
56+
self.auth = auth
57+
self.username = self._resolve_username_from_auth(auth)
58+
elif username and password:
59+
self.auth = HTTPBasicAuth(f'{tenant_id}/{username}', password)
60+
self.username = username
61+
else:
62+
raise ValueError("One of 'auth' or 'username/password' must be defined.")
63+
5964
self.__default_headers = {}
60-
if self.tfa_token:
61-
self.__default_headers['tfatoken'] = self.tfa_token
65+
if tfa_token:
66+
self.__default_headers['tfatoken'] = tfa_token
6267
if self.application_key:
6368
self.__default_headers[self.HEADER_APPLICATION_KEY] = self.application_key
6469
self.session = self._create_session()
6570

6671
def _create_session(self) -> requests.Session:
6772
s = requests.Session()
68-
s.auth = self.__auth
73+
s.auth = self.auth
6974
s.headers = {'Accept': 'application/json'}
7075
if self.application_key:
7176
s.headers.update({self.HEADER_APPLICATION_KEY: self.application_key})
@@ -88,7 +93,7 @@ def prepare_request(self, method: str, resource: str,
8893
hs = self.__default_headers
8994
if additional_headers:
9095
hs.update(additional_headers)
91-
rq = requests.Request(method=method, url=self.base_url + resource, headers=hs, auth=self.__auth)
96+
rq = requests.Request(method=method, url=self.base_url + resource, headers=hs, auth=self.auth)
9297
if json:
9398
rq.json = json
9499
return rq.prepare()
@@ -211,18 +216,20 @@ def post_file(self, resource: str, file: str | BinaryIO, object: dict,
211216
(only 201 is accepted).
212217
"""
213218

214-
def ensure_open_file(f):
215-
if isinstance(f, str):
216-
with open(f, 'rb') as fp:
217-
return fp
218-
return f
219+
def perform_post(open_file):
220+
files = {
221+
'object': (None, json_lib.dumps(object)),
222+
'file': (None, open_file, content_type or 'application/octet-stream')
223+
}
224+
additional_headers = self._prepare_headers(accept=accept)
225+
return self.session.post(self.base_url + resource, files=files, headers=additional_headers)
226+
227+
if isinstance(file, str):
228+
with open(file, 'rb') as f:
229+
r = perform_post(f)
230+
else:
231+
r = perform_post(file)
219232

220-
files = {
221-
'object': (None, json_lib.dumps(object)),
222-
'file': (None, ensure_open_file(file), content_type or 'application/octet-stream')
223-
}
224-
additional_headers = self._prepare_headers(accept=accept)
225-
r = self.session.post(self.base_url + resource, files=files, headers=additional_headers)
226233
if 500 <= r.status_code <= 599:
227234
raise SyntaxError(f"Invalid POST request. Status: {r.status_code} Response:\n" + r.text)
228235
if r.status_code != 201:
@@ -342,6 +349,20 @@ def delete(self, resource: str, json: dict = None, params: dict = None):
342349
if r.status_code not in (200, 204):
343350
raise ValueError(f"Unable to perform DELETE request. Status: {r.status_code} Response:\n" + r.text)
344351

352+
@classmethod
353+
def _resolve_username_from_auth(cls, auth: AuthBase):
354+
"""Resolve the username from the authentication information.
355+
356+
For Basic authentication the username will be simply read from the
357+
provided data, for Bearer authentication the token will be parsed
358+
and the username resolved from the payload.
359+
"""
360+
if isinstance(auth, HTTPBasicAuth):
361+
return auth.username
362+
if isinstance(auth, HTTPBearerAuth):
363+
return JWT(auth.token).username
364+
raise ValueError(f"Unexpected AuthBase instance: {auth.__class__}. Unable to resolve username.")
365+
345366
@classmethod
346367
def _prepare_headers(cls, **kwargs) -> Union[dict, None]:
347368
"""Format a set of named arguments into a header dictionary.

0 commit comments

Comments
 (0)