File size: 5,809 Bytes
5bb3295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
"""
Patient identifiers — YashTech Ltd. AI Labs.

Three rules, enforced here rather than trusted to the UI:

  1. **Identifiers never reach the model.** They label the report; they are not
     clinical context. MedGemma runs on shared multi-tenant hardware, so a name
     in a prompt is a name on someone else's GPU. `scrub()` strips them from
     free text on the way in.
  2. **Pseudonymised by default.** The report header shows a stable token such
     as `PT-K4X9M2QF`, derived by HMAC from the identifier and a secret salt.
     Same patient, same token, across reports and sessions — which is what makes
     a pseudonym useful — without ever writing the identifier down.
  3. **No mapping table.** Re-identification is by recomputing the token from an
     identifier you already hold, not by looking it up. Nothing to leak, nothing
     to subject-access-request, nothing to delete.

Showing a real identifier on a report is a deliberate act by someone holding the
`show_identifiers` permission, and the report says so in its header.

`PSEUDONYM_SALT` must be set as a secret. Without it a random salt is generated
at boot: tokens stay unguessable but stop being stable across restarts, and the
admin console says so rather than quietly producing a different token for the
same patient tomorrow.
"""

from __future__ import annotations

import base64
import dataclasses
import hashlib
import hmac
import os
import re
import secrets

PSEUDONYMISE = "pseudonymise"
OMIT = "omit"
FULL = "full"

MODES = (
    (PSEUDONYMISE, "Pseudonymised token (recommended)"),
    (OMIT, "No identifiers at all"),
    (FULL, "Show as entered (requires permission)"),
)
MODE_CHOICES = [(label, value) for value, label in MODES]
DEFAULT_MODE = PSEUDONYMISE

_EPHEMERAL_SALT = secrets.token_hex(32)


def salt() -> tuple[bytes, bool]:
    """(salt, is_stable). An unset salt is usable but not stable across restarts."""
    configured = os.getenv("PSEUDONYM_SALT", "").strip()
    if configured:
        return configured.encode("utf-8"), True
    return _EPHEMERAL_SALT.encode("utf-8"), False


def salt_status() -> str:
    _key, stable = salt()
    return (
        "configured — tokens are stable across restarts"
        if stable
        else "NOT SET — tokens change on every restart; set PSEUDONYM_SALT as a secret"
    )


def _normalise(value: str) -> str:
    """So `NHS 123 456 7890`, `nhs1234567890` and `123-456-7890` agree."""
    return re.sub(r"[^a-z0-9]", "", (value or "").strip().lower())


def pseudonym(identifier: str, prefix: str = "PT") -> str:
    key, _stable = salt()
    norm = _normalise(identifier)
    if not norm:
        return ""
    digest = hmac.new(key, norm.encode("utf-8"), hashlib.sha256).digest()
    token = base64.b32encode(digest).decode("ascii").rstrip("=")[:8]
    return f"{prefix}-{token}"


@dataclasses.dataclass(frozen=True)
class Patient:
    patient_id: str = ""
    name: str = ""
    mode: str = DEFAULT_MODE

    @property
    def supplied(self) -> bool:
        return bool(self.patient_id.strip() or self.name.strip())

    @property
    def token(self) -> str:
        """Stable token from the ID if there is one, otherwise from the name."""
        return pseudonym(self.patient_id) or pseudonym(self.name, prefix="PN")

    def terms(self) -> list[str]:
        """Strings that must never appear in a prompt."""
        out = []
        for value in (self.patient_id, self.name):
            value = (value or "").strip()
            if len(value) >= 3:
                out.append(value)
                out.extend(p for p in re.split(r"[\s,]+", value) if len(p) >= 3)
        return sorted(set(out), key=len, reverse=True)


def resolve(patient: Patient, may_show_identifiers: bool) -> tuple[Patient, str | None]:
    """Downgrade `full` to pseudonymised when the user lacks the permission."""
    if patient.mode == FULL and not may_show_identifiers:
        return (
            dataclasses.replace(patient, mode=PSEUDONYMISE),
            "Your role cannot show identifiers on a report — exported pseudonymised instead.",
        )
    if patient.mode not in {m for m, _ in MODES}:
        return dataclasses.replace(patient, mode=DEFAULT_MODE), None
    return patient, None


def header_lines(patient: Patient) -> list[str]:
    """Markdown bullets for the report header. Never raises on bad input."""
    if not patient.supplied or patient.mode == OMIT:
        return ["- **Patient:** not recorded (identifiers omitted)"]

    if patient.mode == FULL:
        lines = ["- **Patient identifiers: SHOWN IN FULL — handle as clinical record**"]
        if patient.patient_id.strip():
            lines.append(f"- **Patient ID:** {patient.patient_id.strip()}")
        if patient.name.strip():
            lines.append(f"- **Patient name:** {patient.name.strip()}")
        lines.append(f"- **Pseudonym:** {patient.token}")
        return lines

    lines = [f"- **Patient:** {patient.token} (pseudonymised)"]
    _key, stable = salt()
    if not stable:
        lines.append("- **Note:** pseudonym salt not configured — this token is not reproducible")
    return lines


def scrub(text: str, patient: Patient) -> str:
    """Remove identifiers from text heading for the model.

    Deliberately blunt. This is a backstop for a name typed into the clinical
    indication box, not a de-identification engine: it cannot catch an
    identifier nobody entered into the patient fields.
    """
    if not text or not patient.supplied:
        return text
    cleaned = text
    for term in patient.terms():
        cleaned = re.sub(re.escape(term), "[redacted]", cleaned, flags=re.IGNORECASE)
    return cleaned


def prompt_context(patient: Patient) -> str:
    """What the model is allowed to know: nothing identifying."""
    return ""