///|
/// A lifecycle state for a pseudonym vault.
pub(all) enum VaultState {
Active
Frozen
Retired
} derive(Debug, Eq)
///|
/// One in-memory mapping. Snapshot APIs can omit the original value.
pub(all) struct VaultEntry {
scope : String
kind : PhiKind
original : String
token : String
fingerprint : String
created_sequence : Int
active : Bool
} derive(Debug, Eq)
///|
pub(all) struct VaultStats {
scope : String
state : VaultState
entries : Int
active_entries : Int
kinds : Int
checksum : String
} derive(Debug, Eq)
///|
pub(all) struct PseudonymVault {
scope : String
salt : String
prefix : String
mut entries : Map[String, VaultEntry]
mut reverse : Map[String, String]
mut aliases : Map[String, String]
mut sequence : Int
mut state : VaultState
} derive(Debug)
///|
pub fn PseudonymVault::new(
scope : String,
salt : String,
prefix : String,
) -> PseudonymVault {
{
scope: if scope.trim().is_empty() {
"default"
} else {
scope
},
salt,
prefix,
entries: Map([]),
reverse: Map([]),
aliases: Map([]),
sequence: 0,
state: Active,
}
}
///|
pub fn PseudonymVault::default() -> PseudonymVault {
PseudonymVault::new("default", "moonbit-vault", "PX")
}
///|
pub fn vault_state_name(state : VaultState) -> String {
match state {
Active => "active"
Frozen => "frozen"
Retired => "retired"
}
}
///|
fn vault_key(scope : String, kind : PhiKind, original : String) -> String {
scope + "\u{1f}" + phi_kind_name(kind) + "\u{1f}" + original
}
///|
fn vault_token_key(scope : String, token : String) -> String {
scope + "\u{1f}" + token
}
///|
fn vault_fingerprint(
salt : String,
scope : String,
kind : PhiKind,
original : String,
) -> String {
stable_hash(salt + "\u{1f}" + vault_key(scope, kind, original))
}
///|
fn vault_token_candidate(
prefix : String,
kind : PhiKind,
fingerprint : String,
) -> String {
let clean_prefix = if prefix.is_empty() { "PX" } else { prefix }
clean_prefix + "_" + phi_kind_name(kind) + "_" + fingerprint
}
///|
pub fn PseudonymVault::is_active(self : PseudonymVault) -> Bool {
self.state == Active
}
///|
pub fn PseudonymVault::is_frozen(self : PseudonymVault) -> Bool {
self.state == Frozen
}
///|
pub fn PseudonymVault::is_retired(self : PseudonymVault) -> Bool {
self.state == Retired
}
///|
pub fn PseudonymVault::freeze(self : PseudonymVault) -> Unit {
self.state = Frozen
}
///|
pub fn PseudonymVault::unfreeze(self : PseudonymVault) -> Unit {
if self.state == Frozen {
self.state = Active
}
}
///|
pub fn PseudonymVault::retire(self : PseudonymVault) -> Unit {
self.state = Retired
}
///|
pub fn PseudonymVault::scope(self : PseudonymVault) -> String {
self.scope
}
///|
pub fn PseudonymVault::size(self : PseudonymVault) -> Int {
self.entries.length()
}
///|
pub fn PseudonymVault::active_size(self : PseudonymVault) -> Int {
self.entries.values().filter(fn(item) { item.active }).length()
}
///|
pub fn PseudonymVault::contains(
self : PseudonymVault,
original : String,
kind : PhiKind,
) -> Bool {
self.entries.contains(vault_key(self.scope, kind, original))
}
///|
pub fn PseudonymVault::lookup(
self : PseudonymVault,
original : String,
kind : PhiKind,
) -> VaultEntry? {
self.entries.get(vault_key(self.scope, kind, original))
}
///|
pub fn PseudonymVault::lookup_token(
self : PseudonymVault,
token : String,
) -> VaultEntry? {
match self.reverse.get(vault_token_key(self.scope, token)) {
Some(key) => self.entries.get(key)
None => None
}
}
///|
fn PseudonymVault::collision_token(
self : PseudonymVault,
candidate : String,
fingerprint : String,
) -> String {
match self.reverse.get(vault_token_key(self.scope, candidate)) {
None => candidate
Some(key) =>
if key.has_suffix(fingerprint) {
candidate
} else {
candidate + "_" + stable_hash(key)
}
}
}
///|
pub fn PseudonymVault::token(
self : PseudonymVault,
original : String,
kind : PhiKind,
) -> String {
let key = vault_key(self.scope, kind, original)
match self.entries.get(key) {
Some(entry) => entry.token
None =>
if self.state == Active {
let fingerprint = vault_fingerprint(
self.salt,
self.scope,
kind,
original,
)
self.collision_token(
vault_token_candidate(self.prefix, kind, fingerprint),
fingerprint,
)
} else {
"[UNMAPPED]"
}
}
}
///|
pub fn PseudonymVault::lookup_or_create(
self : PseudonymVault,
original : String,
kind : PhiKind,
) -> String {
let key = vault_key(self.scope, kind, original)
match self.entries.get(key) {
Some(entry) => entry.token
None =>
if self.state != Active {
"[UNMAPPED]"
} else {
let fingerprint = vault_fingerprint(
self.salt,
self.scope,
kind,
original,
)
let token = self.collision_token(
vault_token_candidate(self.prefix, kind, fingerprint),
fingerprint,
)
self.sequence += 1
let entry = {
scope: self.scope,
kind,
original,
token,
fingerprint,
created_sequence: self.sequence,
active: true,
}
self.entries[key] = entry
self.reverse[vault_token_key(self.scope, token)] = key
token
}
}
}
///|
pub fn PseudonymVault::lookup_or_create_for_finding(
self : PseudonymVault,
finding : Finding,
) -> String {
self.lookup_or_create(finding.text, finding.kind)
}
///|
pub fn PseudonymVault::token_for_kind(
self : PseudonymVault,
original : String,
kind : PhiKind,
fallback : String,
) -> String {
let token = self.lookup_or_create(original, kind)
if token == "[UNMAPPED]" {
fallback
} else {
token
}
}
///|
pub fn PseudonymVault::deactivate(
self : PseudonymVault,
original : String,
kind : PhiKind,
) -> Bool {
let key = vault_key(self.scope, kind, original)
match self.entries.get(key) {
Some(entry) => {
self.entries[key] = { ..entry, active: false }
true
}
None => false
}
}
///|
pub fn PseudonymVault::reactivate(
self : PseudonymVault,
original : String,
kind : PhiKind,
) -> Bool {
let key = vault_key(self.scope, kind, original)
match self.entries.get(key) {
Some(entry) => {
self.entries[key] = { ..entry, active: true }
true
}
None => false
}
}
///|
pub fn PseudonymVault::add_alias(
self : PseudonymVault,
alias_value : String,
canonical : String,
kind : PhiKind,
) -> Bool {
match self.entries.get(vault_key(self.scope, kind, canonical)) {
Some(_) => {
self.aliases[vault_key(self.scope, kind, alias_value)] = canonical
true
}
None => false
}
}
///|
pub fn PseudonymVault::resolve_alias(
self : PseudonymVault,
value : String,
kind : PhiKind,
) -> String {
match self.aliases.get(vault_key(self.scope, kind, value)) {
Some(canonical) => canonical
None => value
}
}
///|
pub fn PseudonymVault::alias_count(self : PseudonymVault) -> Int {
self.aliases.length()
}
///|
pub fn PseudonymVault::reidentify(
self : PseudonymVault,
token : String,
) -> String? {
match self.lookup_token(token) {
Some(entry) => if entry.active { Some(entry.original) } else { None }
None => None
}
}
///|
pub fn PseudonymVault::kind_for_token(
self : PseudonymVault,
token : String,
) -> PhiKind? {
self.lookup_token(token).map(fn(entry) { entry.kind })
}
///|
pub fn PseudonymVault::entries_for_kind(
self : PseudonymVault,
kind : PhiKind,
) -> Array[VaultEntry] {
self.entries.values().filter(fn(entry) { entry.kind == kind }).to_array()
}
///|
pub fn PseudonymVault::entries_for_namespace(
self : PseudonymVault,
) -> Array[VaultEntry] {
self.entries.values().to_array()
}
///|
pub fn PseudonymVault::tokens(self : PseudonymVault) -> Array[String] {
self.entries.values().map(fn(entry) { entry.token }).to_array()
}
///|
pub fn PseudonymVault::originals(self : PseudonymVault) -> Array[String] {
self.entries
.values()
.filter(fn(entry) { entry.active })
.map(fn(entry) { entry.original })
.to_array()
}
///|
pub fn PseudonymVault::fingerprints(self : PseudonymVault) -> Array[String] {
self.entries.values().map(fn(entry) { entry.fingerprint }).to_array()
}
///|
pub fn PseudonymVault::stats(self : PseudonymVault) -> VaultStats {
let kinds : Map[String, Unit] = Map([])
for entry in self.entries.values() {
kinds[phi_kind_name(entry.kind)] = ()
}
{
scope: self.scope,
state: self.state,
entries: self.size(),
active_entries: self.active_size(),
kinds: kinds.length(),
checksum: self.checksum(),
}
}
///|
pub fn PseudonymVault::checksum(self : PseudonymVault) -> String {
let lines = []
for key, entry in self.entries {
lines.push(
key + "=" + entry.token + ":" + entry.fingerprint + ":\{entry.active}",
)
}
lines.sort()
stable_hash(lines.join("\n"))
}
///|
pub fn PseudonymVault::ordered_entries(
self : PseudonymVault,
) -> Array[VaultEntry] {
let entries = self.entries.values().to_array()
entries.sort_by(fn(left, right) {
left.created_sequence - right.created_sequence
})
entries
}
///|
pub fn vault_entry_metadata(entry : VaultEntry) -> String {
[
"scope=\{entry.scope}",
"kind=\{phi_kind_name(entry.kind)}",
"token=\{entry.token}",
"fingerprint=\{entry.fingerprint}",
"sequence=\{entry.created_sequence}",
"active=\{entry.active}",
].join("\n")
}
///|
pub fn PseudonymVault::kind_counts(self : PseudonymVault) -> Map[String, Int] {
let counts : Map[String, Int] = Map([])
for entry in self.entries.values() {
let key = phi_kind_name(entry.kind)
counts[key] = counts.get_or_default(key, 0) + 1
}
counts
}
///|
pub fn PseudonymVault::active_tokens(self : PseudonymVault) -> Array[String] {
self.entries
.values()
.filter(fn(entry) { entry.active })
.map(fn(entry) { entry.token })
.to_array()
}
///|
pub fn PseudonymVault::inactive_tokens(self : PseudonymVault) -> Array[String] {
self.entries
.values()
.filter(fn(entry) { !entry.active })
.map(fn(entry) { entry.token })
.to_array()
}
///|
pub fn PseudonymVault::entry_by_sequence(
self : PseudonymVault,
sequence : Int,
) -> VaultEntry? {
let mut result : VaultEntry? = None
for entry in self.entries.values() {
if entry.created_sequence == sequence {
result = Some(entry)
}
}
result
}
///|
pub fn PseudonymVault::next_sequence(self : PseudonymVault) -> Int {
self.sequence + 1
}
///|
pub fn PseudonymVault::namespace_checksum(self : PseudonymVault) -> String {
stable_hash(self.scope + "\u{1f}" + self.salt)
}
///|
pub fn vault_entry_json(entry : VaultEntry, include_original : Bool) -> String {
let original = if include_original {
",\"original\":\{json_escape(entry.original)}"
} else {
""
}
"{" +
"\"scope\":\{json_escape(entry.scope)}," +
"\"kind\":\{json_escape(phi_kind_name(entry.kind))}," +
"\"token\":\{json_escape(entry.token)}," +
"\"fingerprint\":\{json_escape(entry.fingerprint)}," +
"\"sequence\":\{entry.created_sequence}," +
"\"active\":\{entry.active}" +
original +
"}"
}
///|
pub fn PseudonymVault::snapshot_json(self : PseudonymVault) -> String {
"[" +
self
.ordered_entries()
.map(fn(entry) { vault_entry_json(entry, false) })
.join(",") +
"]"
}
///|
pub fn PseudonymVault::export_json(
self : PseudonymVault,
include_original : Bool,
) -> String {
"{" +
"\"scope\":\{json_escape(self.scope)}," +
"\"state\":\{json_escape(vault_state_name(self.state))}," +
"\"checksum\":\{json_escape(self.checksum())}," +
"\"entries\":[" +
self
.ordered_entries()
.map(fn(entry) { vault_entry_json(entry, include_original) })
.join(",") +
"]}"
}
///|
pub fn PseudonymVault::export_lines(
self : PseudonymVault,
include_original : Bool,
) -> Array[String] {
let lines = [
"vault-version=1",
"scope=\{self.scope}",
"state=\{vault_state_name(self.state)}",
"checksum=\{self.checksum()}",
]
for entry in self.ordered_entries() {
let original = if include_original { entry.original } else { "" }
lines.push(
[
entry.token,
phi_kind_name(entry.kind),
entry.fingerprint,
"\{entry.created_sequence}",
"\{entry.active}",
original,
].join("\t"),
)
}
lines
}
///|
pub fn PseudonymVault::export_text(
self : PseudonymVault,
include_original : Bool,
) -> String {
self.export_lines(include_original).join("\n")
}
///|
pub fn vault_kind_from_name(name : String) -> PhiKind {
match name {
"name" => PersonName
"id" => IdNumber
"phone" => Phone
"date" => Date
"email" => Email
"address" => Address
"medical_record" => MedicalRecord
"insurance" => Insurance
"organization" => Organization
value => Custom(value)
}
}
///|
pub fn vault_entry_from_line(scope : String, line : String) -> VaultEntry? {
let parts = line.split("\t").to_array()
if parts.length() < 5 {
None
} else {
Some({
scope,
kind: vault_kind_from_name(parts[1].to_owned()),
original: if parts.length() >= 6 {
parts[5].to_owned()
} else {
""
},
token: parts[0].to_owned(),
fingerprint: parts[2].to_owned(),
created_sequence: decimal_value(parts[3].to_owned()),
active: parts[4].to_owned() == "true",
})
}
}
///|
pub fn import_vault_lines(
scope : String,
salt : String,
prefix : String,
lines : Array[String],
) -> PseudonymVault {
let vault = PseudonymVault::new(scope, salt, prefix)
for line in lines {
match vault_entry_from_line(vault.scope, line) {
Some(entry) if line.contains("\t") => {
let key = vault_key(vault.scope, entry.kind, entry.original)
vault.entries[key] = entry
vault.reverse[vault_token_key(vault.scope, entry.token)] = key
if entry.created_sequence > vault.sequence {
vault.sequence = entry.created_sequence
}
}
_ => ()
}
}
vault
}
///|
pub fn PseudonymVault::vault_export_safe(self : PseudonymVault) -> Bool {
let exported = self.export_text(false)
!exported.contains("original=") &&
exported.contains("fingerprint") &&
!exported.contains("\t\t")
}
///|
pub fn PseudonymVault::vault_reidentification_round_trip(
self : PseudonymVault,
original : String,
kind : PhiKind,
) -> Bool {
let token = self.lookup_or_create(original, kind)
self.reidentify(token) == Some(original)
}
///|
pub fn PseudonymVault::vault_rotation_preview(
self : PseudonymVault,
next_salt : String,
) -> Map[String, String] {
let result : Map[String, String] = Map([])
for entry in self.entries.values() {
let fingerprint = vault_fingerprint(
next_salt,
self.scope,
entry.kind,
entry.original,
)
result[entry.token] = vault_token_candidate(
self.prefix,
entry.kind,
fingerprint,
)
}
result
}
///|
pub fn PseudonymVault::vault_rotation_report(
self : PseudonymVault,
next_salt : String,
) -> String {
let preview = self.vault_rotation_preview(next_salt)
let lines = [
"scope=\{self.scope}",
"current_checksum=\{self.checksum()}",
"next_salt_checksum=\{stable_hash(next_salt)}",
"entries=\{preview.length()}",
]
for old_token, new_token in preview {
lines.push("\{old_token} -> \{new_token}")
}
lines.join("\n")
}
///|
pub fn vault_merge(
left : PseudonymVault,
right : PseudonymVault,
) -> PseudonymVault {
let result = left
for entry in right.ordered_entries() {
let key = vault_key(result.scope, entry.kind, entry.original)
if !result.entries.contains(key) {
let normalized = { ..entry, scope: result.scope }
result.entries[key] = normalized
result.reverse[vault_token_key(result.scope, normalized.token)] = key
if normalized.created_sequence > result.sequence {
result.sequence = normalized.created_sequence
}
}
}
result
}
///|
pub fn vault_merge_conflicts(
left : PseudonymVault,
right : PseudonymVault,
) -> Array[String] {
let conflicts = []
for entry in right.entries.values() {
match left.lookup(entry.original, entry.kind) {
Some(existing) if existing.token != entry.token =>
conflicts.push(entry.original)
_ => ()
}
}
conflicts
}
///|
pub fn vault_compare(left : PseudonymVault, right : PseudonymVault) -> String {
let conflicts = vault_merge_conflicts(left, right)
[
"left=\{left.size()}",
"right=\{right.size()}",
"conflicts=\{conflicts.length()}",
"same_scope=\{left.scope == right.scope}",
].join("\n")
}
///|
pub fn PseudonymVault::vault_audit_line(self : PseudonymVault) -> String {
let stats = self.stats()
[
"scope=\{stats.scope}",
"state=\{vault_state_name(stats.state)}",
"entries=\{stats.entries}",
"active=\{stats.active_entries}",
"kinds=\{stats.kinds}",
"checksum=\{stats.checksum}",
].join(" ")
}
///|
pub fn PseudonymVault::redact_findings(
self : PseudonymVault,
input : String,
findings : Array[Finding],
) -> DeidResult {
let mapped = findings.map(fn(item) {
{ ..item, replacement: self.lookup_or_create(item.text, item.kind) }
})
let (text, offsets) = apply_findings(input, mapped)
{
text,
findings: mapped,
offsets,
audit: build_audit(input, text, mapped, offsets),
}
}
///|
pub fn PseudonymVault::redact(
self : PseudonymVault,
input : String,
) -> DeidResult raise DeidError {
self.redact_findings(input, scan(input))
}
///|
pub fn PseudonymVault::redact_with_rules(
self : PseudonymVault,
input : String,
rules : Array[Rule],
) -> DeidResult raise DeidError {
self.redact_findings(input, scan(input, rules~))
}
///|
pub fn PseudonymVault::redact_batch(
self : PseudonymVault,
items : Array[BatchItem],
) -> Array[BatchResult] raise DeidError {
let result = []
for item in items {
result.push({ id: item.id, result: self.redact(item.text) })
}
result
}
///|
pub fn vault_token_is_well_formed(token : String) -> Bool {
let parts = token.split("_").to_array()
parts.length() >= 3 &&
parts[0].length() > 0 &&
parts[1].length() > 0 &&
parts[2].length() >= 8 &&
all_ascii_hex(parts[2].to_owned())
}
///|
pub fn vault_tokens_are_unique(tokens : Array[String]) -> Bool {
let seen : Map[String, Unit] = Map([])
let mut unique = true
for token in tokens {
if seen.contains(token) {
unique = false
} else {
seen[token] = ()
}
}
unique
}