Skip to content

Commit f87c357

Browse files
authored
added search on audit (#228)
1 parent ad0bb3b commit f87c357

File tree

6 files changed

+266
-2
lines changed

6 files changed

+266
-2
lines changed

README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ These sections show how to use the SDK to perform permission and user management
5858
7. [Query SSO Groups](#query-sso-groups)
5959
8. [Manage Flows](#manage-flows)
6060
9. [Manage JWTs](#manage-jwts)
61+
10. [Search Audit](#search-audit)
6162

6263
If you wish to run any of our code samples and play with them, check out our [Code Examples](#code-examples) section.
6364

@@ -765,14 +766,29 @@ You can add custom claims to a valid JWT.
765766

766767
```python
767768
updated_jwt = descope_client.mgmt.jwt.update_jwt(
768-
jwt: "original-jwt",
769-
custom_claims: {
769+
jwt="original-jwt",
770+
custom_claims={
770771
"custom-key1": "custom-value1",
771772
"custom-key2": "custom-value2"
772773
},
773774
)
774775
```
775776

777+
### Search Audit
778+
779+
You can perform an audit search for either specific values or full-text across the fields. Audit search is limited to the last 30 days.
780+
Below are some examples. For a full list of available search criteria options, see the function documentation.
781+
782+
```python
783+
# Full text search on last 10 days
784+
audits = descope_client.mgmt.audit.search(
785+
text="some-text",
786+
from_ts=datetime.now(timezone.utc)-timedelta(days=10)
787+
)
788+
# Search successful logins in the last 30 days
789+
audits = descope_client.mgmt.audit.search(actions=["LoginSucceed"])
790+
```
791+
776792
### Utils for your end to end (e2e) tests and integration tests
777793

778794
To ease your e2e tests, we exposed dedicated management methods,

descope/management/audit.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
from datetime import datetime
2+
from typing import List
3+
4+
from descope._auth_base import AuthBase
5+
from descope.management.common import MgmtV1
6+
7+
8+
class Audit(AuthBase):
9+
def search(
10+
self,
11+
user_ids: List[str] = None,
12+
actions: List[str] = None,
13+
excluded_actions: List[str] = None,
14+
devices: List[str] = None,
15+
methods: List[str] = None,
16+
geos: List[str] = None,
17+
remote_addresses: List[str] = None,
18+
login_ids: List[str] = None,
19+
tenants: List[str] = None,
20+
no_tenants: bool = False,
21+
text: str = None,
22+
from_ts: datetime = None,
23+
to_ts: datetime = None,
24+
) -> dict:
25+
"""
26+
Search the audit trail up to last 30 days based on given parameters
27+
28+
Args:
29+
user_ids (List[str]): Optional list of user IDs to filter by
30+
actions (List[str]): Optional list of actions to filter by
31+
excluded_actions (List[str]): Optional list of actions to exclude
32+
devices (List[str]): Optional list of devices to filter by. Current devices supported are "Bot"/"Mobile"/"Desktop"/"Tablet"/"Unknown"
33+
methods (List[str]): Optional list of methods to filter by. Current auth methods are "otp"/"totp"/"magiclink"/"oauth"/"saml"/"password"
34+
geos (List[str]): Optional list of geos to filter by. Geo is currently country code like "US", "IL", etc.
35+
remote_addresses (List[str]): Optional list of remote addresses to filter by
36+
login_ids (List[str]): Optional list of login IDs to filter by
37+
tenants (List[str]): Optional list of tenants to filter by
38+
no_tenants (bool): Should audits without any tenants always be included
39+
text (str): Free text search across all fields
40+
from_ts (datetime): Retrieve records newer than given time but not older than 30 days
41+
to_ts (datetime): Retrieve records older than given time
42+
43+
Return value (dict):
44+
Return dict in the format
45+
{
46+
"audits": [
47+
{
48+
"projectId":"",
49+
"userId": "",
50+
"action": "",
51+
"occurred": 0 (unix-time-milli),
52+
"device": "",
53+
"method": "",
54+
"geo": "",
55+
"remoteAddress": "",
56+
"externalIds": [""],
57+
"tenants": [""],
58+
"data": {
59+
"field1": "field1-value",
60+
"more-details": "in-console-examples"
61+
}
62+
}
63+
]
64+
}
65+
Raise:
66+
AuthException: raised if search operation fails
67+
"""
68+
body = {"noTenants": no_tenants}
69+
if user_ids is not None:
70+
body["userIds"] = user_ids
71+
if actions is not None:
72+
body["actions"] = actions
73+
if excluded_actions is not None:
74+
body["excludedActions"] = excluded_actions
75+
if devices is not None:
76+
body["devices"] = devices
77+
if methods is not None:
78+
body["methods"] = methods
79+
if geos is not None:
80+
body["geos"] = geos
81+
if remote_addresses is not None:
82+
body["remoteAddresses"] = remote_addresses
83+
if login_ids is not None:
84+
body["externalIds"] = login_ids
85+
if tenants is not None:
86+
body["tenants"] = tenants
87+
if text is not None:
88+
body["text"] = text
89+
if from_ts is not None:
90+
body["from"] = from_ts.timestamp * 1000
91+
if to_ts is not None:
92+
body["to"] = to_ts.timestamp * 1000
93+
94+
response = self._auth.do_post(
95+
MgmtV1.audit_search,
96+
body=body,
97+
pswd=self._auth.management_key,
98+
)
99+
return {
100+
"audits": list(map(Audit._convert_audit_record, response.json()["audits"]))
101+
}
102+
103+
@staticmethod
104+
def _convert_audit_record(a: dict) -> dict:
105+
return {
106+
"projectId": a.get("projectId", ""),
107+
"userId": a.get("userId", ""),
108+
"action": a.get("action", ""),
109+
"occurred": datetime.utcfromtimestamp(float(a.get("occurred", "0")) / 1000),
110+
"device": a.get("device", ""),
111+
"method": a.get("method", ""),
112+
"geo": a.get("geo", ""),
113+
"remoteAddress": a.get("remoteAddress", ""),
114+
"loginIds": a.get("externalIds", []),
115+
"tenants": a.get("tenants", []),
116+
"data": a.get("data", {}),
117+
}

descope/management/common.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ class MgmtV1:
7474
group_load_all_for_member_path = "/v1/mgmt/group/member/all"
7575
group_load_all_group_members_path = "/v1/mgmt/group/members"
7676

77+
# Audit
78+
audit_search = "/v1/mgmt/audit/search"
79+
7780

7881
class AssociatedTenant:
7982
"""

descope/mgmt.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from descope.auth import Auth
22
from descope.management.access_key import AccessKey # noqa: F401
3+
from descope.management.audit import Audit # noqa: F401
34
from descope.management.flow import Flow # noqa: F401
45
from descope.management.group import Group # noqa: F401
56
from descope.management.jwt import JWT # noqa: F401
@@ -24,6 +25,7 @@ def __init__(self, auth: Auth):
2425
self._role = Role(auth)
2526
self._group = Group(auth)
2627
self._flow = Flow(auth)
28+
self._audit = Audit(auth)
2729

2830
@property
2931
def tenant(self):
@@ -60,3 +62,7 @@ def group(self):
6062
@property
6163
def flow(self):
6264
return self._flow
65+
66+
@property
67+
def audit(self):
68+
return self._audit
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import logging
2+
import os
3+
import sys
4+
from datetime import datetime
5+
6+
dir_name = os.path.dirname(__file__)
7+
sys.path.insert(0, os.path.join(dir_name, "../"))
8+
from descope import AuthException, DescopeClient # noqa: E402
9+
10+
logging.basicConfig(level=logging.INFO)
11+
12+
13+
def main():
14+
# Either specify here or read from env
15+
project_id = ""
16+
management_key = ""
17+
18+
try:
19+
descope_client = DescopeClient(
20+
project_id=project_id, management_key=management_key
21+
)
22+
try:
23+
logging.info("Going to search audit")
24+
text = None
25+
if len(sys.argv) > 1:
26+
text = sys.argv[1]
27+
from_ts = None
28+
if len(sys.argv) > 2:
29+
from_ts = datetime.fromisoformat(sys.argv[2])
30+
logging.info(descope_client.mgmt.audit.search(text=text, from_ts=from_ts))
31+
32+
except AuthException as e:
33+
logging.info(f"Audit search failed {e}")
34+
35+
except AuthException:
36+
raise
37+
38+
39+
if __name__ == "__main__":
40+
main()

tests/management/test_audit.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from datetime import datetime
2+
from unittest import mock
3+
from unittest.mock import patch
4+
5+
from descope import AuthException, DescopeClient
6+
from descope.common import DEFAULT_TIMEOUT_SECONDS
7+
from descope.management.common import MgmtV1
8+
9+
from .. import common
10+
11+
12+
class TestAudit(common.DescopeTest):
13+
def setUp(self) -> None:
14+
super().setUp()
15+
self.dummy_project_id = "dummy"
16+
self.dummy_management_key = "key"
17+
self.public_key_dict = {
18+
"alg": "ES384",
19+
"crv": "P-384",
20+
"kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4",
21+
"kty": "EC",
22+
"use": "sig",
23+
"x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s",
24+
"y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0",
25+
}
26+
27+
def test_search(self):
28+
client = DescopeClient(
29+
self.dummy_project_id,
30+
self.public_key_dict,
31+
False,
32+
self.dummy_management_key,
33+
)
34+
35+
# Test failed search
36+
with patch("requests.post") as mock_post:
37+
mock_post.return_value.ok = False
38+
self.assertRaises(
39+
AuthException,
40+
client.mgmt.audit.search,
41+
"data",
42+
)
43+
44+
# Test success search
45+
with patch("requests.post") as mock_post:
46+
network_resp = mock.Mock()
47+
network_resp.ok = True
48+
network_resp.json.return_value = {
49+
"audits": [
50+
{
51+
"projectId": "p",
52+
"userId": "u1",
53+
"action": "a1",
54+
"externalIds": ["e1"],
55+
"occurred": str(datetime.now().timestamp() * 1000),
56+
},
57+
{
58+
"projectId": "p",
59+
"userId": "u2",
60+
"action": "a2",
61+
"externalIds": ["e2"],
62+
"occurred": str(datetime.now().timestamp() * 1000),
63+
},
64+
]
65+
}
66+
mock_post.return_value = network_resp
67+
resp = client.mgmt.audit.search()
68+
audits = resp["audits"]
69+
self.assertEqual(len(audits), 2)
70+
self.assertEqual(audits[0]["loginIds"][0], "e1")
71+
mock_post.assert_called_with(
72+
f"{common.DEFAULT_BASE_URL}{MgmtV1.audit_search}",
73+
headers={
74+
**common.default_headers,
75+
"Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}",
76+
},
77+
params=None,
78+
json={"noTenants": False},
79+
allow_redirects=False,
80+
verify=True,
81+
timeout=DEFAULT_TIMEOUT_SECONDS,
82+
)

0 commit comments

Comments
 (0)