thessor 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
thessor/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ from thessor.client import Client
2
+ from thessor.crypto import canonicalize, sha256_hex, hash_patient_id
3
+ from thessor.fhir import FHIRConnector
4
+ from thessor.dicom import DICOMSRConnector
5
+ from thessor.atna import ATNAConnector, ATNAAuditMessage
6
+ from thessor.hl7 import HL7Connector
7
+ from thessor.mcp import ThessorMCPMiddleware
8
+ from thessor.models import SealResult, SealWithRationaleResult, VerifyResult, ChainResult
9
+ from thessor.exceptions import (
10
+ ThessorError, AuthError, NotFoundError,
11
+ ValidationError, RateLimitError, ServerError
12
+ )
13
+
14
+ ThessorClient = Client
15
+
16
+ __version__ = "0.2.0"
17
+ __all__ = [
18
+ "Client",
19
+ "ThessorClient",
20
+ "FHIRConnector", "DICOMSRConnector", "ATNAConnector", "ATNAAuditMessage", "HL7Connector",
21
+ "ThessorMCPMiddleware",
22
+ "SealResult", "SealWithRationaleResult", "VerifyResult", "ChainResult",
23
+ "canonicalize", "sha256_hex", "hash_patient_id",
24
+ "ThessorError", "AuthError", "NotFoundError",
25
+ "ValidationError", "RateLimitError", "ServerError",
26
+ ]
thessor/atna.py ADDED
@@ -0,0 +1,345 @@
1
+ """IHE ATNA (Audit Trail and Node Authentication) connector for the Thessor SDK.
2
+
3
+ Hospitals run ATNA Audit Record Repositories (ARR) today. This connector slots
4
+ directly into that infrastructure: it emits DICOM PS3.15 / RFC 3881 conformant
5
+ AuditMessage XML, ships it to the ARR over UDP or TLS syslog, and then seals
6
+ the message itself through a thessor.Client — making the hospital's own audit
7
+ trail tamper-evident.
8
+
9
+ Transport is RFC 5424 syslog (UDP) or RFC 5425 syslog (TLS with octet-count
10
+ framing). The message format is AuditMessage XML with a UTF-8 declaration.
11
+
12
+ Stdlib only — xml.etree.ElementTree, socket, ssl, base64. No third-party
13
+ dependencies.
14
+ """
15
+
16
+ import base64
17
+ import socket
18
+ import ssl
19
+ import xml.etree.ElementTree as ET
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime
22
+
23
+ from .crypto import sha256_hex
24
+ from .models import SealResult
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Constants
28
+ # ---------------------------------------------------------------------------
29
+
30
+ # DCM event codes.
31
+ EVENT_APPLICATION_ACTIVITY = "110100"
32
+ EVENT_IMPORT = "110107"
33
+ EVENT_EXPORT = "110106"
34
+ EVENT_QUERY = "110112"
35
+ EVENT_AI_DECISION_SEALED = "THSR-001" # Thessor extension code
36
+
37
+ # Event action codes.
38
+ ACTION_CREATE = "C"
39
+ ACTION_READ = "R"
40
+ ACTION_EXECUTE = "E"
41
+
42
+ # Outcome indicators.
43
+ OUTCOME_SUCCESS = "0"
44
+ OUTCOME_MINOR_FAILURE = "4"
45
+ OUTCOME_SERIOUS_FAILURE = "8"
46
+ OUTCOME_MAJOR_FAILURE = "12"
47
+
48
+ # Role ID codes.
49
+ ROLE_APPLICATION = "110150"
50
+ ROLE_DESTINATION = "110152"
51
+ ROLE_SOURCE = "110153"
52
+
53
+ # Participant object type codes.
54
+ OBJ_TYPE_SYSTEM = "2"
55
+
56
+ # Participant object type code roles.
57
+ OBJ_ROLE_JOB = "20"
58
+
59
+ THESSOR_AUDIT_SOURCE_ID = "api.thessor.com"
60
+ THESSOR_CODING_SCHEME = "THESSOR"
61
+
62
+ DEFAULT_SYSLOG_PORT_UDP = 514
63
+ DEFAULT_SYSLOG_PORT_TLS = 6514
64
+
65
+ # RFC 5424 syslog priority: facility=1 (user-level), severity=5 (notice).
66
+ _SYSLOG_PRIORITY = 13
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # ATNAAuditMessage dataclass
71
+ # ---------------------------------------------------------------------------
72
+
73
+ @dataclass
74
+ class ATNAAuditMessage:
75
+ """A single IHE ATNA audit event, serialisable to DICOM PS3.15 XML."""
76
+
77
+ event_id: str # DCM code e.g. EVENT_IMPORT
78
+ event_action: str # ACTION_EXECUTE etc.
79
+ event_datetime: str # ISO 8601 UTC
80
+ event_outcome: str # OUTCOME_SUCCESS etc.
81
+ user_id: str # caller identity
82
+ audit_source_id: str # originating system
83
+ participant_object_id: str # the sealed object ID (envelope_id)
84
+ participant_object_details: dict # str → str extra fields
85
+ event_type_code: str | None = None
86
+ event_type_code_scheme: str = "DCM"
87
+ user_name: str | None = None
88
+ network_access_point: str | None = None
89
+
90
+ # Human-readable labels for the fixed DCM event codes used here.
91
+ _EVENT_ID_TEXT: dict = field(default_factory=lambda: {
92
+ EVENT_APPLICATION_ACTIVITY: "Application Activity",
93
+ EVENT_IMPORT: "Import",
94
+ EVENT_EXPORT: "Export",
95
+ EVENT_QUERY: "Query",
96
+ EVENT_AI_DECISION_SEALED: "AI Decision Sealed",
97
+ }, init=False, repr=False, compare=False)
98
+
99
+ def to_xml(self) -> str:
100
+ """Build and return a DICOM PS3.15 AuditMessage XML string.
101
+
102
+ ParticipantObjectDetail values are base64-encoded per the ATNA
103
+ standard. The string is prefixed with an XML 1.0 UTF-8 declaration.
104
+ """
105
+ root = ET.Element("AuditMessage")
106
+
107
+ # EventIdentification
108
+ ei = ET.SubElement(root, "EventIdentification",
109
+ EventActionCode=self.event_action,
110
+ EventDateTime=self.event_datetime,
111
+ EventOutcomeIndicator=self.event_outcome)
112
+
113
+ event_text = self._EVENT_ID_TEXT.get(self.event_id, self.event_id)
114
+ ET.SubElement(ei, "EventID",
115
+ attrib={
116
+ "csd-code": self.event_id,
117
+ "codeSystemName": "DCM",
118
+ "originalText": event_text,
119
+ })
120
+
121
+ if self.event_type_code is not None:
122
+ ET.SubElement(ei, "EventTypeCode",
123
+ attrib={
124
+ "csd-code": self.event_type_code,
125
+ "codeSystemName": self.event_type_code_scheme,
126
+ "originalText": "AI Decision Sealed",
127
+ })
128
+
129
+ # ActiveParticipant
130
+ ap_attribs = {
131
+ "UserID": self.user_id,
132
+ "UserIsRequestor": "true",
133
+ }
134
+ if self.user_name is not None:
135
+ ap_attribs["UserName"] = self.user_name
136
+ if self.network_access_point is not None:
137
+ ap_attribs["NetworkAccessPointID"] = self.network_access_point
138
+ ap = ET.SubElement(root, "ActiveParticipant", attrib=ap_attribs)
139
+ ET.SubElement(ap, "RoleIDCode",
140
+ attrib={
141
+ "csd-code": ROLE_APPLICATION,
142
+ "codeSystemName": "DCM",
143
+ "originalText": "Application",
144
+ })
145
+
146
+ # AuditSourceIdentification
147
+ asi = ET.SubElement(root, "AuditSourceIdentification",
148
+ AuditSourceID=self.audit_source_id)
149
+ ET.SubElement(asi, "AuditSourceTypeCode",
150
+ attrib={
151
+ "csd-code": "4",
152
+ "codeSystemName": "DCM",
153
+ "originalText": "Application Server",
154
+ })
155
+
156
+ # ParticipantObjectIdentification
157
+ poi = ET.SubElement(root, "ParticipantObjectIdentification",
158
+ ParticipantObjectID=self.participant_object_id,
159
+ ParticipantObjectTypeCode=OBJ_TYPE_SYSTEM,
160
+ ParticipantObjectTypeCodeRole=OBJ_ROLE_JOB)
161
+ ET.SubElement(poi, "ParticipantObjectIDTypeCode",
162
+ attrib={
163
+ "csd-code": "110182",
164
+ "codeSystemName": "DCM",
165
+ "originalText": "Node ID",
166
+ })
167
+
168
+ for key, value in (self.participant_object_details or {}).items():
169
+ encoded = base64.b64encode(str(value).encode("utf-8")).decode("ascii")
170
+ ET.SubElement(poi, "ParticipantObjectDetail",
171
+ type=key, value=encoded)
172
+
173
+ xml_body = ET.tostring(root, encoding="unicode", xml_declaration=False)
174
+ return '<?xml version="1.0" encoding="UTF-8"?>\n' + xml_body
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # ATNAConnector
179
+ # ---------------------------------------------------------------------------
180
+
181
+ class ATNAConnector:
182
+ """Send ATNA-conformant audit messages and optionally seal them.
183
+
184
+ Wraps a Thessor Client and provides three independent capabilities:
185
+ - Building an ATNAAuditMessage from a SealResult.
186
+ - Shipping the message to a live ARR over UDP or TLS syslog.
187
+ - Sealing the message XML itself, making the audit trail tamper-evident.
188
+ """
189
+
190
+ def __init__(self, client: "Client"): # noqa: F821 — forward ref, avoids import cycle
191
+ self.client = client
192
+
193
+ def build_audit_message(
194
+ self,
195
+ result: SealResult,
196
+ *,
197
+ user_id: str = "thessor-system",
198
+ event_id: str = EVENT_IMPORT,
199
+ event_action: str = ACTION_EXECUTE,
200
+ outcome: str = OUTCOME_SUCCESS,
201
+ extra_details: dict | None = None,
202
+ ) -> ATNAAuditMessage:
203
+ """Build an ATNAAuditMessage from a Thessor SealResult."""
204
+ details: dict = {
205
+ "ThessorEnvelopeID": result.envelope_id or "",
206
+ "ThessorVerifyURL": result.verify_url or "",
207
+ "SealVersion": result.seal_version or "",
208
+ }
209
+ if extra_details:
210
+ details.update(extra_details)
211
+
212
+ event_datetime = result.created_at or (datetime.utcnow().isoformat() + "Z")
213
+
214
+ return ATNAAuditMessage(
215
+ event_id=event_id,
216
+ event_action=event_action,
217
+ event_datetime=event_datetime,
218
+ event_outcome=outcome,
219
+ user_id=user_id,
220
+ audit_source_id=THESSOR_AUDIT_SOURCE_ID,
221
+ participant_object_id=result.envelope_id or "pending",
222
+ participant_object_details=details,
223
+ event_type_code=EVENT_AI_DECISION_SEALED,
224
+ event_type_code_scheme=THESSOR_CODING_SCHEME,
225
+ )
226
+
227
+ def send_udp(
228
+ self,
229
+ message: ATNAAuditMessage,
230
+ host: str,
231
+ port: int = DEFAULT_SYSLOG_PORT_UDP,
232
+ ) -> None:
233
+ """Send the audit message to an ARR via UDP syslog (RFC 5424).
234
+
235
+ Raises ConnectionError on any socket failure.
236
+ """
237
+ xml = message.to_xml()
238
+ timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
239
+ hostname = socket.gethostname()
240
+ syslog_msg = f"<{_SYSLOG_PRIORITY}>1 {timestamp} {hostname} thessor - - - {xml}"
241
+ payload = syslog_msg.encode("utf-8")
242
+ try:
243
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
244
+ sock.sendto(payload, (host, port))
245
+ except socket.error as exc:
246
+ raise ConnectionError(f"UDP syslog send failed: {exc}") from exc
247
+
248
+ def send_tls(
249
+ self,
250
+ message: ATNAAuditMessage,
251
+ host: str,
252
+ port: int = DEFAULT_SYSLOG_PORT_TLS,
253
+ *,
254
+ ca_cert_path: str | None = None,
255
+ client_cert_path: str | None = None,
256
+ client_key_path: str | None = None,
257
+ timeout: float = 5.0,
258
+ ) -> None:
259
+ """Send the audit message to an ARR via TLS syslog (RFC 5425).
260
+
261
+ Uses octet-count framing: the message is prefixed with its byte length
262
+ followed by a space. Raises ConnectionError on any failure.
263
+ """
264
+ xml = message.to_xml()
265
+ timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
266
+ hostname = socket.gethostname()
267
+ syslog_msg = f"<{_SYSLOG_PRIORITY}>1 {timestamp} {hostname} thessor - - - {xml}"
268
+ msg_bytes = syslog_msg.encode("utf-8")
269
+ framed = f"{len(msg_bytes)} ".encode("utf-8") + msg_bytes
270
+
271
+ try:
272
+ if ca_cert_path is not None:
273
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
274
+ ctx.load_verify_locations(ca_cert_path)
275
+ else:
276
+ ctx = ssl.create_default_context()
277
+
278
+ if client_cert_path and client_key_path:
279
+ ctx.load_cert_chain(client_cert_path, client_key_path)
280
+
281
+ with socket.create_connection((host, port), timeout=timeout) as raw_sock:
282
+ with ctx.wrap_socket(raw_sock, server_hostname=host) as tls_sock:
283
+ tls_sock.sendall(framed)
284
+ except (socket.error, ssl.SSLError, OSError) as exc:
285
+ raise ConnectionError(f"TLS syslog send failed: {exc}") from exc
286
+
287
+ def seal_audit_message(
288
+ self,
289
+ message: ATNAAuditMessage,
290
+ *,
291
+ sync: bool = True,
292
+ ) -> SealResult:
293
+ """Seal the ATNA audit message XML itself via Thessor.
294
+
295
+ This makes the hospital's own audit trail tamper-evident: altering the
296
+ audit log after the fact breaks the Ed25519 signature.
297
+ """
298
+ xml = message.to_xml()
299
+ payload = {
300
+ "atna_xml": xml,
301
+ "envelope_id": message.participant_object_id,
302
+ }
303
+ return self.client.seal(
304
+ decision_id=f"atna-{message.participant_object_id}",
305
+ model_id="atna-audit",
306
+ model_version="1.0",
307
+ timestamp=message.event_datetime,
308
+ input_hash=sha256_hex(xml.encode("utf-8")),
309
+ output={
310
+ "event_id": message.event_id,
311
+ "outcome": message.event_outcome,
312
+ },
313
+ confidence_score=1.0,
314
+ policy_version="v1",
315
+ payload=payload,
316
+ sync=sync,
317
+ )
318
+
319
+ def build_and_seal(
320
+ self,
321
+ result: SealResult,
322
+ *,
323
+ send_to: str | None = None,
324
+ port: int = DEFAULT_SYSLOG_PORT_UDP,
325
+ use_tls: bool = False,
326
+ **build_kwargs,
327
+ ) -> tuple[ATNAAuditMessage, SealResult]:
328
+ """Build an ATNA message, optionally ship it, then seal it.
329
+
330
+ Steps:
331
+ 1. build_audit_message(result, **build_kwargs)
332
+ 2. If send_to is set: send via TLS or UDP accordingly.
333
+ 3. seal_audit_message(message)
334
+ 4. Return (ATNAAuditMessage, atna SealResult).
335
+ """
336
+ message = self.build_audit_message(result, **build_kwargs)
337
+
338
+ if send_to is not None:
339
+ if use_tls:
340
+ self.send_tls(message, send_to, port)
341
+ else:
342
+ self.send_udp(message, send_to, port)
343
+
344
+ atna_seal = self.seal_audit_message(message)
345
+ return message, atna_seal