Skip to content

Commit a9296c5

Browse files
jerrybelmonteakodali18JyuqiJohn Cornish
authored
Add CSP authentication support (#111)
Signed-off-by: John Cornish <[email protected]> Co-authored-by: Anil Kodali <[email protected]> Co-authored-by: Yuqi Jin <[email protected]> Co-authored-by: John Cornish <[email protected]>
1 parent a51ef6b commit a9296c5

14 files changed

+559
-15
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ lib/
88
lib64/
99
env/
1010
venv/
11+
.idea/
12+
.vscode/

example.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,26 @@ def send_event(wavefront_client):
8282
def main():
8383
"""Send sample metrics in a loop."""
8484
wavefront_proxy_url = sys.argv[1]
85+
csp_base_url, csp_api_token = None, None
86+
csp_app_id, csp_app_secret, csp_org_id = None, None, None
87+
88+
if len(sys.argv) == 4:
89+
csp_base_url = sys.argv[2]
90+
csp_api_token = sys.argv[3]
91+
elif len(sys.argv) > 4:
92+
csp_base_url = sys.argv[2]
93+
csp_app_id = sys.argv[3]
94+
csp_app_secret = sys.argv[4]
95+
if len(sys.argv) > 5:
96+
csp_org_id = sys.argv[5]
8597

8698
client_factory = WavefrontClientFactory()
87-
client_factory.add_client(wavefront_proxy_url)
99+
client_factory.add_client(wavefront_proxy_url,
100+
csp_base_url=csp_base_url,
101+
csp_api_token=csp_api_token,
102+
csp_app_id=csp_app_id,
103+
csp_app_secret=csp_app_secret,
104+
csp_org_id=csp_org_id)
88105
wfront_client = client_factory.get_client()
89106

90107
try:
@@ -104,4 +121,16 @@ def main():
104121
# Either "proxy://our.proxy.lb.com:2878"
105122
# Or "https://[email protected]"
106123
# should be passed as an input in sys.argv[1]
124+
# Or "https://DOMAIN.wavefront.com" "https://CSP_ENDPOINT.cloud.vmware.com"
125+
# "CSP_API_TOKEN"
126+
# should be passed as an input in sys.argv[1], sys.argv[2],
127+
# and sys.argv[3]
128+
# Or "https://DOMAIN.wavefront.com" "https://CSP_ENDPOINT.cloud.vmware.com"
129+
# "CSP_APP_ID" "CSP_APP_SECRET"
130+
# should be passed as an input in sys.argv[1], sys.argv[2],
131+
# sys.argv[3], and sys.argv[4]
132+
# Or "https://DOMAIN.wavefront.com" "https://CSP_ENDPOINT.cloud.vmware.com"
133+
# "CSP_APP_ID" "CSP_APP_SECRET" "ORG_ID"
134+
# should be passed as an input in sys.argv[1], sys.argv[2],
135+
# sys.argv[3], sys.argv[4], and sys.argv[5]
107136
main()

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
setuptools.setup(
1313
name='wavefront-sdk-python',
14-
version='2.0.1', # Please update with each pull request.
14+
version='2.1.0', # Please update with each pull request.
1515
author='VMware Aria Operations for Applications Team',
1616
url='https://github.com/wavefrontHQ/wavefront-sdk-python',
1717
license='Apache-2.0',

test/test_client.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import unittest
77
import uuid
8+
from unittest.mock import Mock
89

910
import requests
1011

@@ -23,7 +24,7 @@ def setUp(self):
2324
enable_internal_metrics=False)
2425
self._spans_log_buffer = self._sender._spans_log_buffer
2526
self._tracing_spans_buffer = self._sender._tracing_spans_buffer
26-
self._response = unittest.mock.Mock()
27+
self._response = Mock()
2728
self._response.status_code = 200
2829

2930
def test_send_version_with_internal_metrics(self):
@@ -119,7 +120,7 @@ def test_report_event(self):
119120
self._sender._report('',
120121
self._sender.WAVEFRONT_EVENT_FORMAT,
121122
'',
122-
unittest.mock.Mock())
123+
Mock())
123124
mock_post.assert_called_once_with(unittest.mock.ANY,
124125
headers=unittest.mock.ANY,
125126
data=unittest.mock.ANY)
@@ -129,7 +130,7 @@ def test_report_non_event(self):
129130
with unittest.mock.patch.object(
130131
requests.Session, 'post',
131132
return_value=self._response) as mock_post:
132-
self._sender._report('', 'metric', '', unittest.mock.Mock())
133+
self._sender._report('', 'metric', '', Mock())
133134
mock_post.assert_called_once_with(unittest.mock.ANY,
134135
params=unittest.mock.ANY,
135136
headers=unittest.mock.ANY,

test/test_wavefront_client.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,41 @@ def test_get_client(self):
5757
wavefront_sdk.multi_clients
5858
.WavefrontMultiClient))
5959

60+
def test_get_csp_client(self):
61+
"""Test get_client of WavefrontClientFactory
62+
for client with CSP configuration
63+
"""
64+
65+
wavefront_cluster_base_url = "https://csp-enabled.wavefront.com"
66+
67+
# if there is only one client and using CSP OAuth App ID and App Secret
68+
fake_oauth_app_id = "fake-csp-app-id"
69+
fake_oauth_app_secret = "fake-csp-app-secret"
70+
multi_client_factory = WavefrontClientFactory()
71+
multi_client_factory.add_client(
72+
wavefront_cluster_base_url,
73+
csp_app_id=fake_oauth_app_id,
74+
csp_app_secret=fake_oauth_app_secret,
75+
)
76+
self.assertTrue(isinstance(multi_client_factory.get_client(),
77+
wavefront_sdk.client.WavefrontClient))
78+
self.assertTrue(
79+
multi_client_factory.existing_client(wavefront_cluster_base_url)
80+
)
81+
82+
# if there is only one client and using CSP Api Token
83+
fake_csp_api_token = "fake-csp-api-token"
84+
multi_client_factory = WavefrontClientFactory()
85+
multi_client_factory.add_client(
86+
wavefront_cluster_base_url,
87+
csp_api_token=fake_csp_api_token,
88+
)
89+
self.assertTrue(isinstance(multi_client_factory.get_client(),
90+
wavefront_sdk.client.WavefrontClient))
91+
self.assertTrue(
92+
multi_client_factory.existing_client(wavefront_cluster_base_url)
93+
)
94+
6095
def test_existing_client(self):
6196
"""Test existing_client of WavefrontClientFactory"""
6297
server = "http://192.114.71.230:2878"

wavefront_sdk/auth/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Wavefront SDK Authentication.
2+
3+
@author Jerry Belmonte ([email protected])
4+
"""
5+
6+
from .csp.authorize_response import AuthorizeResponse
7+
from .csp.csp_request import CSPAPIToken, CSPClientCredentials
8+
from .csp.csp_token_service import CSPAccessTokenService
9+
from .csp.token_service_factory import TokenServiceProvider
10+
11+
12+
__all__ = ['AuthorizeResponse',
13+
'CSPAPIToken',
14+
'CSPClientCredentials',
15+
'CSPAccessTokenService',
16+
'TokenServiceProvider']

wavefront_sdk/auth/csp/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""CSP Authentication.
2+
3+
@author Jerry Belmonte ([email protected])
4+
"""
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""CSP Authorize Response.
2+
3+
@author Jerry Belmonte ([email protected])
4+
"""
5+
6+
from dataclasses import dataclass, field
7+
8+
9+
@dataclass
10+
class AuthorizeResponse:
11+
"""Authorize Response."""
12+
13+
access_token: str = field(default='', repr=False)
14+
expires_in: int = 0
15+
16+
def set_auth_response(self, response):
17+
"""Set the CSP auth response.
18+
19+
@param response: The json-encoded response.
20+
"""
21+
self.access_token = response.get("access_token")
22+
self.expires_in = response.get("expires_in")
23+
24+
def get_time_offset(self):
25+
"""Calculate the time offset.
26+
27+
Calculates the time offset for scheduling regular requests to CSP based
28+
on the expiration time of the token. If the access token expiration
29+
time is less than 10 minutes, schedule requests 30 seconds before it
30+
expires. If the access token expiration time is 10 minutes or more,
31+
schedule requests 3 minutes before it expires.
32+
33+
@return: The expiration time offset of the CSP access token in seconds.
34+
"""
35+
if self.expires_in < 600:
36+
return self.expires_in - 30
37+
return self.expires_in - 180

wavefront_sdk/auth/csp/csp_request.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""CSP Request types.
2+
3+
@author Jerry Belmonte ([email protected])
4+
"""
5+
6+
from base64 import b64encode
7+
from dataclasses import dataclass
8+
9+
10+
@dataclass(frozen=True)
11+
class AuthServerURL:
12+
"""Auth Server URL."""
13+
14+
base_url: str
15+
auth_path: str
16+
17+
def get_server_url(self):
18+
"""Get the full authentication server URL.
19+
20+
@return: The authentication server URL.
21+
"""
22+
if self.base_url.endswith('/'):
23+
return self.base_url[:-1] + self.auth_path
24+
return self.base_url + self.auth_path
25+
26+
27+
@dataclass(frozen=True)
28+
class CSPAPIToken(AuthServerURL):
29+
"""CSP Api Token."""
30+
31+
token: str
32+
33+
def get_data(self):
34+
"""Get the HTTP request body.
35+
36+
@return: The data for the request body.
37+
"""
38+
return {'api_token': self.token}
39+
40+
def get_headers(self):
41+
"""Get the HTTP request headers.
42+
43+
@return: The parameters for request headers.
44+
"""
45+
return {'Content-Type': 'application/x-www-form-urlencoded'}
46+
47+
48+
@dataclass(frozen=True)
49+
class CSPClientCredentials(AuthServerURL):
50+
"""CSP Client Credentials."""
51+
52+
client_id: str
53+
client_secret: str
54+
org_id: str = ''
55+
56+
def get_data(self):
57+
"""Get the HTTP request body.
58+
59+
@return: The data for the request body.
60+
"""
61+
data = {'grant_type': 'client_credentials'}
62+
if self.org_id:
63+
data['orgId'] = self.org_id
64+
return data
65+
66+
def encode_csp_credentials(self):
67+
"""Encode the CSP client credentials.
68+
69+
@return: Base64 encoded client credentials.
70+
"""
71+
csp_credentials = self.client_id + ":" + self.client_secret
72+
return b64encode(csp_credentials.encode("utf-8")).decode("utf-8")
73+
74+
def get_headers(self):
75+
"""Get the HTTP request headers.
76+
77+
@return: The parameters for request headers.
78+
"""
79+
return {'Authorization': f'Basic {self.encode_csp_credentials()}',
80+
'Content-Type': 'application/x-www-form-urlencoded'}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""CSP Token Service implementation.
2+
3+
@author Jerry Belmonte ([email protected])
4+
"""
5+
6+
import logging
7+
from time import time
8+
9+
from requests import HTTPError, Timeout, post
10+
11+
from .authorize_response import AuthorizeResponse
12+
from .token_service import TokenService
13+
14+
15+
LOGGER = logging.getLogger('wavefront_sdk.auth.csp.CSPAccessTokenService')
16+
CSP_API_TOKEN_SERVICE_TYPE = 'API_TOKEN'
17+
CSP_OAUTH_TOKEN_SERVICE_TYPE = 'OAUTH'
18+
CSP_REQUEST_TIMEOUT_SEC = 30
19+
20+
21+
class CSPAccessTokenService(TokenService):
22+
"""CSP Access Token Service Implementation."""
23+
24+
def __init__(self, csp_type: str, csp_service):
25+
"""Construct CSP Access Token Service.
26+
27+
@param csp_type: The csp token service type.
28+
@param csp_service: The csp service object.
29+
"""
30+
self._csp_type = csp_type
31+
self._csp_service = csp_service
32+
self._csp_access_token = None
33+
self._token_expiration_time = 0
34+
self._csp_response = None
35+
36+
def get_type(self):
37+
"""Get the token service type.
38+
39+
@return: The service type.
40+
"""
41+
return self._csp_type
42+
43+
def get_token(self):
44+
"""Get the token service access token.
45+
46+
@return: The access token.
47+
"""
48+
LOGGER.debug("Retrieving the CSP access token.")
49+
try:
50+
response = post(self._csp_service.get_server_url(),
51+
data=self._csp_service.get_data(),
52+
headers=self._csp_service.get_headers(),
53+
timeout=CSP_REQUEST_TIMEOUT_SEC)
54+
code = response.status_code
55+
if code == 200:
56+
self._csp_response = AuthorizeResponse()
57+
self._csp_response.set_auth_response(response.json())
58+
self._csp_access_token = self._csp_response.access_token
59+
self._token_expiration_time =\
60+
time() + self._csp_response.get_time_offset()
61+
LOGGER.info("CSP auth token refresh succeeded, access token"
62+
" expires in %d seconds.",
63+
self._csp_response.expires_in)
64+
return self._csp_access_token
65+
if not response.ok:
66+
data = response.json()
67+
LOGGER.error("CSP auth token refresh failed with status code:"
68+
" %d %s", code, data.get("message"))
69+
response.raise_for_status()
70+
71+
except HTTPError as error:
72+
LOGGER.error("CSP HTTP Error: %s", error)
73+
except ConnectionError as error:
74+
LOGGER.error("CSP Connection Error: %s", error)
75+
except Timeout as error:
76+
LOGGER.error("CSP Timeout Error: %s", error)
77+
return None
78+
79+
def get_csp_access_token(self):
80+
"""Get the CSP access token.
81+
82+
@return: The access token.
83+
"""
84+
if not self._csp_access_token or time() >= self._token_expiration_time:
85+
return self.get_token()
86+
return self._csp_access_token
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""CSP Token Service.
2+
3+
@author Jerry Belmonte ([email protected])
4+
"""
5+
6+
from abc import ABC, abstractmethod
7+
8+
9+
class TokenService(ABC):
10+
"""Service that gets access tokens."""
11+
12+
@abstractmethod
13+
def get_token(self) -> str:
14+
"""Get the token service access token.
15+
16+
@return: The access token.
17+
"""
18+
19+
@abstractmethod
20+
def get_type(self) -> str:
21+
"""Get the token service type.
22+
23+
@return: The service type.
24+
"""

0 commit comments

Comments
 (0)