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 +26 -0
- thessor/atna.py +345 -0
- thessor/client.py +469 -0
- thessor/crypto.py +50 -0
- thessor/dicom.py +364 -0
- thessor/exceptions.py +51 -0
- thessor/fhir.py +354 -0
- thessor/hl7.py +345 -0
- thessor/mcp.py +144 -0
- thessor/models.py +128 -0
- thessor-0.2.0.dist-info/METADATA +358 -0
- thessor-0.2.0.dist-info/RECORD +13 -0
- thessor-0.2.0.dist-info/WHEEL +4 -0
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
|