Skip to content

Commit 96d4c62

Browse files
authored
feat: AuditEvent data model (M2-10698) (#2046)
πŸ”— [Jira Ticket M2-10698](https://mindlogger.atlassian.net/browse/M2-10698) Changes include: - Define `AuditEvent` data model - Define `audit.log` stub We try to use standard Elastic Common Schema (ECS) fields where possible.
1 parent f167b04 commit 96d4c62

5 files changed

Lines changed: 270 additions & 1 deletion

File tree

β€Žsrc/apps/audit/__init__.pyβ€Ž

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .domain import AuditEvent
2+
from .enums import EventAction, EventOutcome
3+
from .service import log
4+
5+
__all__ = ["AuditEvent", "EventAction", "EventOutcome", "log"]

β€Žsrc/apps/audit/domain.pyβ€Ž

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
from datetime import datetime, timezone
2+
from typing import Annotated
3+
from uuid import UUID, uuid4
4+
5+
from pydantic import ConfigDict, Field
6+
7+
from apps.shared.domain import PublicModel
8+
from config import settings
9+
10+
from .enums import EventAction, EventKind, EventOutcome
11+
12+
13+
class AuditEvent(PublicModel):
14+
"""Audit event
15+
16+
Required for all events:
17+
- user_id
18+
- event_action
19+
20+
Automatically populated fields:
21+
- timestamp
22+
- event_id
23+
- event_kind
24+
- event_module
25+
- event_dataset
26+
- service_name
27+
- service_environment
28+
29+
Applicable to IAM events:
30+
- user_roles
31+
- user_target_id
32+
- user_target_email
33+
- user_target_roles
34+
35+
Applicable to failures:
36+
- event_outcome
37+
- error_type
38+
39+
Applicable to HTTP requests:
40+
- client_ip
41+
- http_request_id
42+
- http_request_method,
43+
- http_response_status_code
44+
- url_path
45+
- url_query
46+
47+
Applicable to HTTP requests if Datadog is enabled:
48+
- trace_id
49+
50+
Applicable to file downloads:
51+
- file_path
52+
53+
Applicable to Curious database records:
54+
- curious_applet_id
55+
- curious_subject_id
56+
- curious_flow_id
57+
- curious_activity_id
58+
- curious_submit_id
59+
- curious_answer_id
60+
61+
Notes:
62+
- Set user_id=None if there is no authenticated user.
63+
- Set event_outcome="failure" for failures.
64+
65+
"""
66+
67+
model_config = ConfigDict(serialize_by_alias=True)
68+
69+
timestamp: Annotated[
70+
datetime,
71+
Field(alias="@timestamp", default_factory=lambda: datetime.now(timezone.utc)),
72+
]
73+
error_type: Annotated[str | None, Field(alias="error.type")] = None # if event_outcome="failure"
74+
event_action: Annotated[EventAction, Field(alias="event.action")]
75+
event_id: Annotated[UUID, Field(alias="event.id", default_factory=uuid4)]
76+
event_kind: Annotated[EventKind, Field(alias="event.kind")] = EventKind.EVENT
77+
event_outcome: Annotated[EventOutcome, Field(alias="event.outcome")] = EventOutcome.SUCCESS
78+
event_module: Annotated[str, Field(alias="event.module")] = "curious"
79+
event_dataset: Annotated[str, Field(alias="event.dataset")] = "curious.audit"
80+
service_name: Annotated[str, Field(alias="service.name", default=settings.service.name)]
81+
service_environment: Annotated[str, Field(alias="service.environment", default=settings.env)]
82+
83+
# User performing the action
84+
user_id: Annotated[UUID | None, Field(alias="user.id")]
85+
user_roles: Annotated[list[str] | None, Field(alias="user.roles")] = None
86+
87+
# User being acted upon (if applicable)
88+
user_target_id: Annotated[UUID | None, Field(alias="user.target.id")] = None # prefer ID if available
89+
user_target_email: Annotated[str | None, Field(alias="user.target.email")] = None # email only if ID unavailable
90+
user_target_roles: Annotated[list[str] | None, Field(alias="user.target.roles")] = None
91+
92+
# For HTTP requests
93+
client_ip: Annotated[str | None, Field(alias="client.ip")] = None
94+
http_request_id: Annotated[str | None, Field(alias="http.request.id")] = None # from asgi-correlation-id
95+
http_request_method: Annotated[str | None, Field(alias="http.request.method")] = None
96+
http_response_status_code: Annotated[int | None, Field(alias="http.response.status_code")] = None
97+
trace_id: Annotated[str | None, Field(alias="trace.id")] = None # from Datadog if enabled
98+
url_path: Annotated[str | None, Field(alias="url.path")] = None
99+
url_query: Annotated[str | None, Field(alias="url.query")] = None
100+
user_agent: Annotated[str | None, Field(alias="user_agent.original")] = None
101+
102+
# For file downloads
103+
file_path: Annotated[str | None, Field(alias="file.path")] = None # file download path
104+
105+
# For Curious database records
106+
curious_applet_id: Annotated[list[UUID] | None, Field(alias="curious.applet_id")] = None
107+
curious_subject_id: Annotated[list[UUID] | None, Field(alias="curious.subject_id")] = None
108+
curious_flow_id: Annotated[list[UUID] | None, Field(alias="curious.flow_id")] = None
109+
curious_activity_id: Annotated[list[UUID] | None, Field(alias="curious.activity_id")] = None
110+
curious_submit_id: Annotated[list[UUID] | None, Field(alias="curious.submit_id")] = None
111+
curious_answer_id: Annotated[list[UUID] | None, Field(alias="curious.answer_id")] = None

β€Žsrc/apps/audit/enums.pyβ€Ž

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
from enum import StrEnum
2+
from functools import cached_property
3+
4+
5+
class EventAction(StrEnum):
6+
"""event.action values defined for Curious
7+
8+
Uses colon-separated format "{resource}:{subject}:{action}":
9+
- "{resource}" all lowercase or underscore
10+
- "{subject}" all lowercase or underscore or colon or omitted
11+
- "{action}" all lowercase or underscore
12+
13+
Note that "{subject}" can contain colons or be omitted.
14+
15+
If omitted, "{subject}" is implied to be the same as "{resource}".
16+
"""
17+
18+
# User auth
19+
USER_SESSION_LOGIN = "user:session:login"
20+
USER_SESSION_LOGOUT = "user:session:logout"
21+
USER_SESSION_REFRESH = "user:session:refresh"
22+
USER_SESSION_INVALID = "user:session:invalid"
23+
24+
# User IAM
25+
USER_CREATE = "user:create"
26+
USER_DELETE = "user:delete"
27+
USER_PASSWORD_CHANGE = "user:password:change"
28+
USER_PASSWORD_RECOVERY_INITIATE = "user:password:recovery:initiate"
29+
USER_PASSWORD_RECOVERY_APPROVE = "user:password:recovery:approve"
30+
USER_MFA_ENABLE = "user:mfa:enable"
31+
USER_MFA_DISABLE = "user:mfa:disable"
32+
USER_MFA_RECOVERY_VIEW = "user:mfa:recovery:view"
33+
USER_MFA_RECOVERY_DOWNLOAD = "user:mfa:recovery:download"
34+
USER_MFA_RECOVERY_USE = "user:mfa:recovery:use"
35+
36+
# Workspace IAM
37+
WORKSPACE_ACCESS_GRANT = "workspace:access:grant"
38+
WORKSPACE_ACCESS_REVOKE = "workspace:access:revoke"
39+
40+
# Applet IAM
41+
APPLET_CREATE = "applet:create"
42+
APPLET_DELETE = "applet:delete"
43+
APPLET_ENCRYPTION_UPDATE = "applet:encryption:update"
44+
APPLET_TRANSFER_INITIATE = "applet:transfer:initiate"
45+
APPLET_TRANSFER_ACCEPT = "applet:transfer:accept"
46+
APPLET_TRANSFER_DECLINE = "applet:transfer:decline"
47+
APPLET_INVITE_INITIATE = "applet:invite:initiate"
48+
APPLET_INVITE_ACCEPT = "applet:invite:accept"
49+
APPLET_INVITE_DECLINE = "applet:invite:decline"
50+
51+
# Applet data access
52+
APPLET_SUBJECT_VIEW = "applet:subject:view"
53+
APPLET_ANSWER_VIEW = "applet:answer:view"
54+
APPLET_ANSWER_IDENTIFIER_VIEW = "applet:answer:identifier:view"
55+
APPLET_ANSWER_ASSESSMENT_VIEW = "applet:answer:assessment:view"
56+
APPLET_ANSWER_NOTE_VIEW = "applet:answer:note:view"
57+
APPLET_ANSWER_EXPORT = "applet:answer:export"
58+
59+
# Applet file download
60+
APPLET_ANSWER_EHR_DOWNLOAD = "applet:answer:ehr:download"
61+
APPLET_ANSWER_FILE_DOWNLOAD = "applet:answer:file:download"
62+
APPLET_ANSWER_REPORT_DOWNLOAD = "applet:answer:report:download"
63+
64+
@cached_property
65+
def _parts(self) -> tuple[str, ...]:
66+
"""Split into parts by colon ":"
67+
68+
- ("a", "b", "c", "d") for event "a:b:c:d"
69+
- ("foo", "bar", "baz") for event "foo:bar:baz"
70+
- ("toto", "tata") for event "toto:tata"
71+
"""
72+
return tuple(self.value.split(":"))
73+
74+
@cached_property
75+
def resource(self) -> str:
76+
"""First part of enum value
77+
78+
- "a" for event "a:b:c:d"
79+
- "foo" for event "foo:bar:baz"
80+
- "toto" for event "toto:tata"
81+
"""
82+
return self._parts[0]
83+
84+
@cached_property
85+
def subject(self) -> str:
86+
"""Middle part of enum value, or resource if middle part does not exist
87+
88+
- "b:c" for event "a:b:c:d"
89+
- "bar" for event "foo:bar:baz"
90+
- "toto" for event "toto:tata"
91+
"""
92+
return ":".join(self._parts[1:-1]) or self.resource
93+
94+
@cached_property
95+
def action(self) -> str:
96+
"""Last part of enum value
97+
98+
- "d" for event "a:b:c:d"
99+
- "baz" for event "foo:bar:baz"
100+
- "tata" for event "toto:tata"
101+
"""
102+
return self._parts[-1]
103+
104+
105+
class EventKind(StrEnum):
106+
"""event.kind values defined by ECS"""
107+
108+
# https://www.elastic.co/docs/reference/ecs/ecs-allowed-values-event-kind
109+
EVENT = "event"
110+
111+
112+
class EventCategory(StrEnum):
113+
"""event.category values defined by ECS"""
114+
115+
# https://www.elastic.co/docs/reference/ecs/ecs-allowed-values-event-category
116+
AUTHENTICATION = "authentication"
117+
SESSION = "session"
118+
DATABASE = "database"
119+
FILE = "file"
120+
IAM = "iam"
121+
CONFIGURATION = "configuration"
122+
WEB = "web"
123+
124+
125+
class EventType(StrEnum):
126+
"""event.type values defined by ECS"""
127+
128+
# https://www.elastic.co/docs/reference/ecs/ecs-allowed-values-event-type
129+
START = "start"
130+
END = "end"
131+
INFO = "info"
132+
ACCESS = "access"
133+
CHANGE = "change"
134+
CREATION = "creation"
135+
DELETION = "deletion"
136+
DENIED = "denied"
137+
138+
139+
class EventOutcome(StrEnum):
140+
"""event.outcome values defined by ECS"""
141+
142+
# https://www.elastic.co/docs/reference/ecs/ecs-allowed-values-event-outcome
143+
SUCCESS = "success"
144+
FAILURE = "failure"
145+
UNKNOWN = "unknown"

β€Žsrc/apps/audit/service.pyβ€Ž

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from infrastructure.logger import logger
2+
3+
from .domain import AuditEvent
4+
5+
6+
async def log(event: AuditEvent) -> None:
7+
payload = event.model_dump(mode="json")
8+
logger.info("audit_event", **payload) # TODO: Replace with sending event to RabbitMQ/OpenSearch.

β€Žsrc/config/service.pyβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class ServiceUrlsSettings(BaseModel):
2828
class ServiceSettings(BaseModel):
2929
"""Configure public service settings."""
3030

31-
name: str = "mindlogger-service"
31+
name: str = "curious-backend"
3232
port: int = 8000
3333
urls: ServiceUrlsSettings = ServiceUrlsSettings()
3434
result_limit: Annotated[int, Field(gt=0)] = 10000

0 commit comments

Comments
Β (0)