Skip to content

Commit f3e3f25

Browse files
author
Rohan Jadvani
authored
Create client for Audit Logs (#9)
* WIP * Undo staging url * Re-factor validation to a decorator * Add unit tests to client * Fix formatting * Re-factor per PR feedback * Fix formatting
1 parent 8bb8c4f commit f3e3f25

File tree

7 files changed

+193
-18
lines changed

7 files changed

+193
-18
lines changed

tests/test_audit_log.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from datetime import datetime
2+
import json
3+
from requests import Response
4+
5+
import pytest
6+
7+
import workos
8+
from workos.audit_log import AuditLog
9+
10+
11+
class TestSSO(object):
12+
@pytest.fixture(autouse=True)
13+
def setup(self, set_api_key_and_project_id):
14+
self.audit_log = AuditLog()
15+
16+
def test_create_audit_log_event_succeeds(self, mock_request_method):
17+
event = {
18+
"group": "Terrace House",
19+
"location": "1.1.1.1",
20+
"action": "house.created",
21+
"action_type": "C",
22+
"actor_name": "Daiki Miyagi",
23+
"actor_id": "user_12345",
24+
"target_name": "Ryota Yamasato",
25+
"target_id": "user_67890",
26+
"occurred_at": datetime.utcnow().isoformat(),
27+
"metadata": {"a": "b"},
28+
}
29+
mock_response = Response()
30+
mock_response.status_code = 200
31+
mock_request_method("post", mock_response, 200)
32+
response = self.audit_log.create_event(event)
33+
assert response.status_code == 200
34+
35+
def test_create_audit_log_event_fails_with_long_metadata(self):
36+
with pytest.raises(Exception, match=r"Number of metadata keys exceeds .*"):
37+
metadata = {str(num): num for num in range(51)}
38+
event = {
39+
"group": "Terrace House",
40+
"location": "1.1.1.1",
41+
"action": "house.created",
42+
"action_type": "C",
43+
"actor_name": "Daiki Miyagi",
44+
"actor_id": "user_12345",
45+
"target_name": "Ryota Yamasato",
46+
"target_id": "user_67890",
47+
"occurred_at": datetime.utcnow().isoformat(),
48+
"metadata": metadata,
49+
}
50+
self.audit_log.create_event(event)

tests/test_client.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ class TestClient(object):
88
@pytest.fixture(autouse=True)
99
def setup(self):
1010
client._sso = None
11+
client._audit_log = None
1112

1213
def test_initialize_sso(self, set_api_key_and_project_id):
1314
assert bool(client.sso)
1415

16+
def test_initialize_audit_log(self, set_api_key_and_project_id):
17+
assert bool(client.audit_log)
18+
1519
def test_initialize_sso_missing_api_key(self, set_project_id):
1620
with pytest.raises(ConfigurationException) as ex:
1721
client.sso
@@ -37,3 +41,29 @@ def test_initialize_sso_missing_api_key_and_project_id(self):
3741
message = str(ex)
3842

3943
assert all(setting in message for setting in ("api_key", "project_id",))
44+
45+
def test_initialize_audit_log_missing_api_key(self, set_project_id):
46+
with pytest.raises(ConfigurationException) as ex:
47+
client.audit_log
48+
49+
message = str(ex)
50+
51+
assert "api_key" in message
52+
assert "project_id" not in message
53+
54+
def test_initialize_audit_log_missing_project_id(self, set_api_key):
55+
with pytest.raises(ConfigurationException) as ex:
56+
client.audit_log
57+
58+
message = str(ex)
59+
60+
assert "project_id" in message
61+
assert "api_key" not in message
62+
63+
def test_initialize_audit_log_missing_api_key_and_project_id(self):
64+
with pytest.raises(ConfigurationException) as ex:
65+
client.audit_log
66+
67+
message = str(ex)
68+
69+
assert all(setting in message for setting in ("api_key", "project_id",))

workos/audit_log.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import workos
2+
from workos.exceptions import ConfigurationException
3+
from workos.utils.request import RequestHelper, REQUEST_METHOD_POST
4+
from workos.utils.validation import validate_api_key_and_project_id
5+
6+
EVENTS_PATH = "events"
7+
METADATA_LIMIT = 50
8+
9+
10+
class AuditLog(object):
11+
"""Offers methods through the WorkOS Audit Log service."""
12+
13+
@validate_api_key_and_project_id("Audit Log")
14+
def __init__(self):
15+
pass
16+
17+
@property
18+
def request_helper(self):
19+
if not getattr(self, "_request_helper", None):
20+
self._request_helper = RequestHelper()
21+
return self._request_helper
22+
23+
def create_event(self, event, idempotency_key=None):
24+
"""Create an Audit Log event.
25+
26+
Args:
27+
event (dict) - An event object
28+
event[action] (str) - Specific activity performed by the actor.
29+
event[action_type] (str) - Corresponding CRUD category of the
30+
event. Can be one of C, R, U, or D.
31+
event[actor_name] (str) - Display name of the entity performing the action
32+
event[actor_id] (str) - Unique identifier of the entity performing the action
33+
event[group] (str) - A single organization containing related .
34+
members. This will normally be the customer of a vendor's application
35+
event[location] (str) - Identifier for where the event
36+
originated. This will be an IP address (IPv4 or IPv6),
37+
hostname, or device ID.
38+
event[occurred_at] (str) - ISO-8601 datetime at which the event
39+
happened, with millisecond precision.
40+
event[metadata] (str) - Arbitrary key-value data containing
41+
information associated with the event. Note: There is a limit of 50
42+
keys. Key names can be up to 40 characters long, and values can be up
43+
to 500 characters long.
44+
event[target_id] (str) - Unique identifier of the object or
45+
resource being acted upon.
46+
event[target_name] (str) - Display name of the object or
47+
resource that is being acted upon.
48+
idempotency_key (str) - An idempotency key
49+
50+
Returns:
51+
dict: Response from WorkOS
52+
"""
53+
if len(event.get("metadata", {})) > METADATA_LIMIT:
54+
raise Exception("Number of metadata keys exceeds %d." % METADATA_LIMIT)
55+
56+
headers = {
57+
"Authorization": "Bearer %s" % workos.api_key,
58+
"idempotency_key": idempotency_key,
59+
}
60+
61+
return self.request_helper.request(
62+
EVENTS_PATH, method=REQUEST_METHOD_POST, params=event, headers=headers
63+
)

workos/client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from workos.audit_log import AuditLog
12
from workos.sso import SSO
23

34

@@ -10,5 +11,11 @@ def sso(self):
1011
self._sso = SSO()
1112
return self._sso
1213

14+
@property
15+
def audit_log(self):
16+
if not getattr(self, "_audit_log", None):
17+
self._audit_log = AuditLog()
18+
return self._audit_log
19+
1320

1421
client = Client()

workos/sso.py

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from workos.exceptions import ConfigurationException
77
from workos.resources.sso import WorkOSProfile
88
from workos.utils.request import RequestHelper, RESPONSE_TYPE_CODE, REQUEST_METHOD_POST
9+
from workos.utils.validation import validate_api_key_and_project_id
910

1011
AUTHORIZATION_PATH = "sso/authorize"
1112
TOKEN_PATH = "sso/token"
@@ -16,23 +17,9 @@
1617
class SSO(object):
1718
"""Offers methods to assist in authenticating through the WorkOS SSO service."""
1819

20+
@validate_api_key_and_project_id("SSO")
1921
def __init__(self):
20-
required_settings = [
21-
"api_key",
22-
"project_id",
23-
]
24-
25-
missing_settings = []
26-
for setting in required_settings:
27-
if not getattr(workos, setting, None):
28-
missing_settings.append(setting)
29-
30-
if missing_settings:
31-
raise ConfigurationException(
32-
"The following settings are missing for SSO: {}".format(
33-
", ".join(missing_settings)
34-
)
35-
)
22+
pass
3623

3724
@property
3825
def request_helper(self):

workos/utils/request.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def set_base_api_url(self, base_api_url):
3333
def generate_api_url(self, path):
3434
return self.base_api_url.format(path)
3535

36-
def request(self, path, method=REQUEST_METHOD_GET, params=None):
36+
def request(self, path, method=REQUEST_METHOD_GET, params=None, headers=None):
3737
"""Executes a request against the WorkOS API.
3838
3939
Args:
@@ -46,9 +46,16 @@ def request(self, path, method=REQUEST_METHOD_GET, params=None):
4646
Returns:
4747
dict: Response from WorkOS
4848
"""
49+
if headers is None:
50+
headers = {}
51+
headers.update(BASE_HEADERS)
4952
url = self.generate_api_url(path)
5053

51-
response = getattr(requests, method)(url, headers=BASE_HEADERS, params=params)
54+
request_fn = getattr(requests, method)
55+
if method == REQUEST_METHOD_GET:
56+
response = request_fn(url, headers=headers, params=params)
57+
else:
58+
response = request_fn(url, headers=headers, data=params)
5259

5360
status_code = response.status_code
5461
if status_code >= 400 and status_code < 500:

workos/utils/validation.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from functools import wraps
2+
3+
import workos
4+
from workos.exceptions import ConfigurationException
5+
6+
7+
def validate_api_key_and_project_id(module_name):
8+
def decorator(fn):
9+
@wraps(fn)
10+
def wrapper(*args, **kwargs):
11+
required_settings = [
12+
"api_key",
13+
"project_id",
14+
]
15+
16+
missing_settings = []
17+
for setting in required_settings:
18+
if not getattr(workos, setting, None):
19+
missing_settings.append(setting)
20+
21+
if missing_settings:
22+
raise ConfigurationException(
23+
"The following settings are missing for {}: {}".format(
24+
", ".join(missing_settings), module_name
25+
)
26+
)
27+
return fn(*args, **kwargs)
28+
29+
return wrapper
30+
31+
return decorator

0 commit comments

Comments
 (0)