///|
/// A typed action for a structured resource field.
pub(all) enum ResourceAction {
Retain
Redact
Hash
Remove
Review
} derive(Debug, Eq)
///|
pub(all) struct ResourceField {
path : String
value : String
kind : PhiKind
action : ResourceAction
required : Bool
source : String
} derive(Debug, Eq)
///|
pub(all) struct ResourceDocument {
resource_type : String
mut resource_id : String
mut fields : Array[ResourceField]
mut metadata : Map[String, String]
} derive(Debug)
///|
pub(all) struct ResourcePolicy {
name : String
default_action : ResourceAction
sensitive_paths : Array[String]
protected_paths : Array[String]
required_paths : Array[String]
config : RedactionConfig
} derive(Debug, Eq)
///|
pub(all) struct ResourceIssue {
path : String
code : String
message : String
severity : DiagnosticSeverity
} derive(Debug, Eq)
///|
pub(all) struct ResourceReport {
resource_type : String
resource_id : String
input_fields : Int
output_fields : Int
changed_fields : Int
removed_fields : Int
review_fields : Int
issues : Array[ResourceIssue]
checksum : String
} derive(Debug)
///|
pub fn resource_action_name(action : ResourceAction) -> String {
match action {
Retain => "retain"
Redact => "redact"
Hash => "hash"
Remove => "remove"
Review => "review"
}
}
///|
pub fn resource_kind_for_path(path : String) -> PhiKind {
let value = path.to_lower()
if value.contains("name") || value.contains("patient") {
PersonName
} else if value.contains("phone") || value.contains("telecom") {
Phone
} else if value.contains("email") {
Email
} else if value.contains("birth") || value.contains("date") {
Date
} else if value.contains("address") || value.contains("street") {
Address
} else if value.contains("insurance") || value.contains("coverage") {
Insurance
} else if value.contains("mrn") || value.contains("medical") {
MedicalRecord
} else if value.contains("identifier") || value.has_suffix(".id") {
IdNumber
} else if value.contains("hospital") || value.contains("organization") {
Organization
} else {
Custom("structured")
}
}
///|
pub fn resource_field(
path : String,
value : String,
action : ResourceAction,
) -> ResourceField {
{
path,
value,
kind: resource_kind_for_path(path),
action,
required: false,
source: "input",
}
}
///|
pub fn resource_field_with_kind(
path : String,
value : String,
kind : PhiKind,
action : ResourceAction,
) -> ResourceField {
{ ..resource_field(path, value, action), kind, }
}
///|
pub fn ResourceDocument::new(
resource_type : String,
resource_id : String,
fields : Array[ResourceField],
) -> ResourceDocument {
{ resource_type, resource_id, fields, metadata: Map([]) }
}
///|
pub fn ResourceDocument::empty(resource_type : String) -> ResourceDocument {
ResourceDocument::new(resource_type, "", [])
}
///|
pub fn ResourceDocument::field_count(self : ResourceDocument) -> Int {
self.fields.length()
}
///|
pub fn ResourceDocument::has_path(
self : ResourceDocument,
path : String,
) -> Bool {
self.fields.any(fn(field) { field.path == path })
}
///|
pub fn ResourceDocument::get(
self : ResourceDocument,
path : String,
) -> ResourceField? {
let mut result : ResourceField? = None
for field in self.fields {
if field.path == path {
result = Some(field)
}
}
result
}
///|
pub fn ResourceDocument::values(
self : ResourceDocument,
path : String,
) -> Array[String] {
self.fields
.filter(fn(field) { field.path == path })
.map(fn(field) { field.value })
}
///|
pub fn ResourceDocument::paths(self : ResourceDocument) -> Array[String] {
self.fields.map(fn(field) { field.path })
}
///|
pub fn ResourceDocument::add(
self : ResourceDocument,
field : ResourceField,
) -> Unit {
self.fields.push(field)
}
///|
pub fn ResourceDocument::put(
self : ResourceDocument,
field : ResourceField,
) -> Unit {
let mut replaced = false
for i in 0.. Int {
let kept = self.fields.filter(fn(field) { field.path != path })
let removed = self.fields.length() - kept.length()
self.fields = kept
removed
}
///|
pub fn ResourceDocument::with_metadata(
self : ResourceDocument,
key : String,
value : String,
) -> Unit {
self.metadata[key] = value
}
///|
pub fn ResourceDocument::metadata_value(
self : ResourceDocument,
key : String,
) -> String? {
self.metadata.get(key)
}
///|
pub fn ResourceDocument::copy(self : ResourceDocument) -> ResourceDocument {
{
resource_type: self.resource_type,
resource_id: self.resource_id,
fields: self.fields.copy(),
metadata: self.metadata.copy(),
}
}
///|
pub fn ResourcePolicy::default() -> ResourcePolicy {
{
name: "clinical-resource",
default_action: Retain,
sensitive_paths: [
"Patient.name", "Patient.identifier", "Patient.birthDate", "Patient.address",
"Patient.telecom", "Observation.note", "Encounter.subject", "Coverage.subscriber",
],
protected_paths: [],
required_paths: [],
config: RedactionConfig::default(),
}
}
///|
pub fn ResourcePolicy::strict() -> ResourcePolicy {
{
..ResourcePolicy::default(),
name: "strict-resource",
default_action: Redact,
config: { ..RedactionConfig::default(), policy: RedactionPolicy::strict() },
}
}
///|
pub fn resource_policy_with_paths(
policy : ResourcePolicy,
paths : Array[String],
) -> ResourcePolicy {
{ ..policy, sensitive_paths: paths }
}
///|
pub fn resource_policy_protect(
policy : ResourcePolicy,
paths : Array[String],
) -> ResourcePolicy {
let guarded = policy.protected_paths.copy()
guarded.append(paths)
{ ..policy, protected_paths: guarded }
}
///|
pub fn resource_policy_require(
policy : ResourcePolicy,
paths : Array[String],
) -> ResourcePolicy {
let required = policy.required_paths.copy()
required.append(paths)
{ ..policy, required_paths: required }
}
///|
pub fn resource_policy_action(
policy : ResourcePolicy,
path : String,
) -> ResourceAction {
if policy.protected_paths.contains(path) {
Retain
} else if policy.sensitive_paths.contains(path) {
Redact
} else {
policy.default_action
}
}
///|
pub fn resource_path_matches(pattern : String, path : String) -> Bool {
if pattern == "*" {
true
} else if pattern.has_suffix(".*") {
path.has_prefix(pattern[:pattern.length() - 1])
} else if pattern.has_prefix("*.") {
path.has_suffix(pattern[1:])
} else {
pattern == path
}
}
///|
pub fn resource_any_path_matches(
patterns : Array[String],
path : String,
) -> Bool {
patterns.any(fn(pattern) { resource_path_matches(pattern, path) })
}
///|
pub fn resource_effective_action(
policy : ResourcePolicy,
field : ResourceField,
) -> ResourceAction {
if resource_any_path_matches(policy.protected_paths, field.path) {
Retain
} else if resource_any_path_matches(policy.sensitive_paths, field.path) {
Redact
} else if field.action != Retain {
field.action
} else {
policy.default_action
}
}
///|
pub fn resource_apply_action(
field : ResourceField,
action : ResourceAction,
config : RedactionConfig,
) -> ResourceField raise DeidError {
match action {
Retain => { ..field, action: Retain }
Redact => {
let result = redact_with_config(field.value, config)
{ ..field, value: result.text, action: Redact }
}
Hash => {
let replacement = "[\{phi_kind_name(field.kind)}]#\{stable_hash(field.value)}"
{ ..field, value: replacement, action: Hash }
}
Remove => { ..field, value: "", action: Remove }
Review => { ..field, action: Review }
}
}
///|
pub fn resource_redact(
document : ResourceDocument,
policy : ResourcePolicy,
) -> ResourceDocument raise DeidError {
let result = document.copy()
let fields = document.fields.copy()
result.fields = []
for field in fields {
let action = resource_effective_action(policy, field)
result.fields.push(resource_apply_action(field, action, policy.config))
}
result
}
///|
pub fn resource_changed_paths(
before : ResourceDocument,
after : ResourceDocument,
) -> Array[String] {
let paths = []
for field in before.fields {
match after.get(field.path) {
Some(other) => if other.value != field.value { paths.push(field.path) }
None => paths.push(field.path)
}
}
paths
}
///|
pub fn resource_removed_paths(
before : ResourceDocument,
after : ResourceDocument,
) -> Array[String] {
before.paths().filter(fn(path) { !after.has_path(path) })
}
///|
pub fn resource_review_paths(document : ResourceDocument) -> Array[String] {
document.fields
.filter(fn(field) { field.action == Review })
.map(fn(field) { field.path })
}
///|
pub fn resource_validate(
document : ResourceDocument,
policy : ResourcePolicy,
) -> Array[ResourceIssue] {
let issues = []
if document.resource_type.trim().is_empty() {
issues.push({
path: "",
code: "RESOURCE_TYPE",
message: "resource type is empty",
severity: Error,
})
}
for path in policy.required_paths {
match document.get(path) {
Some(field) if !field.value.trim().is_empty() => ()
_ =>
issues.push({
path,
code: "REQUIRED_FIELD",
message: "required field is missing",
severity: Error,
})
}
}
for field in document.fields {
if field.path.trim().is_empty() {
issues.push({
path: field.path,
code: "EMPTY_PATH",
message: "field path is empty",
severity: Error,
})
}
if field.value.length() > 1000000 {
issues.push({
path: field.path,
code: "FIELD_TOO_LARGE",
message: "field exceeds one million code units",
severity: Warning,
})
}
}
issues
}
///|
pub fn resource_report(
before : ResourceDocument,
after : ResourceDocument,
policy : ResourcePolicy,
) -> ResourceReport {
let issues = resource_validate(after, policy)
let changed = resource_changed_paths(before, after)
let removed = resource_removed_paths(before, after)
{
resource_type: after.resource_type,
resource_id: after.resource_id,
input_fields: before.field_count(),
output_fields: after.field_count(),
changed_fields: changed.length(),
removed_fields: removed.length(),
review_fields: resource_review_paths(after).length(),
issues,
checksum: stable_hash(resource_render(after)),
}
}
///|
pub fn resource_render(document : ResourceDocument) -> String {
let lines = [
"resource_type=\{document.resource_type}",
"resource_id=\{document.resource_id}",
]
for key, value in document.metadata {
lines.push("meta.\{key}=\{value}")
}
for field in document.fields {
lines.push("\{field.path}=\{field.value}")
}
lines.join("\n")
}
///|
pub fn resource_parse(text : String) -> ResourceDocument {
let fields = []
let mut resource_type = ""
let mut resource_id = ""
let metadata : Map[String, String] = Map([])
for field in parse_fields(text) {
if field.key == "resource_type" {
resource_type = field.value
} else if field.key == "resource_id" {
resource_id = field.value
} else if field.key.has_prefix("meta.") {
metadata[field.key[5:].to_owned()] = field.value
} else {
fields.push(resource_field(field.key, field.value, Retain))
}
}
{ resource_type, resource_id, fields, metadata }
}
///|
pub fn resource_from_map(
resource_type : String,
resource_id : String,
values : Map[String, String],
) -> ResourceDocument {
let document = ResourceDocument::empty(resource_type)
document.resource_id = resource_id
for path, value in values {
document.fields.push(resource_field(path, value, Retain))
}
document
}
///|
pub fn resource_to_map(document : ResourceDocument) -> Map[String, String] {
let result : Map[String, String] = Map([])
for field in document.fields {
result[field.path] = field.value
}
result
}
///|
pub fn resource_report_text(report : ResourceReport) -> String {
[
"resource_type=\{report.resource_type}",
"resource_id=\{report.resource_id}",
"input_fields=\{report.input_fields}",
"output_fields=\{report.output_fields}",
"changed_fields=\{report.changed_fields}",
"removed_fields=\{report.removed_fields}",
"review_fields=\{report.review_fields}",
"issues=\{report.issues.length()}",
"checksum=\{report.checksum}",
].join("\n")
}
///|
pub fn resource_issue_text(issue : ResourceIssue) -> String {
[
"\{severity_name(issue.severity)}",
"\{issue.code}",
"\{issue.path}",
issue.message,
].join(" ")
}
///|
pub fn resource_issues_text(issues : Array[ResourceIssue]) -> String {
issues.map(resource_issue_text).join("\n")
}
///|
pub fn resource_report_json(report : ResourceReport) -> String {
let issues = report.issues
.map(fn(issue) {
"{" +
"\"path\":\{json_escape(issue.path)}," +
"\"code\":\{json_escape(issue.code)}," +
"\"severity\":\{json_escape(severity_name(issue.severity))}," +
"\"message\":\{json_escape(issue.message)}" +
"}"
})
.join(",")
"{" +
"\"resource_type\":\{json_escape(report.resource_type)}," +
"\"resource_id\":\{json_escape(report.resource_id)}," +
"\"input_fields\":\{report.input_fields}," +
"\"output_fields\":\{report.output_fields}," +
"\"changed_fields\":\{report.changed_fields}," +
"\"removed_fields\":\{report.removed_fields}," +
"\"review_fields\":\{report.review_fields}," +
"\"checksum\":\{json_escape(report.checksum)}," +
"\"issues\":[" +
issues +
"]}"
}
///|
pub fn resource_is_safe(
before : ResourceDocument,
after : ResourceDocument,
policy : ResourcePolicy,
) -> Bool {
let report = resource_report(before, after, policy)
!report.issues.any(fn(issue) { issue.severity == Error }) &&
resource_removed_paths(before, after).all(fn(path) {
resource_any_path_matches(policy.sensitive_paths, path)
})
}
///|
pub fn resource_non_sensitive_values_preserved(
before : ResourceDocument,
after : ResourceDocument,
policy : ResourcePolicy,
) -> Bool {
let mut safe = true
for field in before.fields {
if resource_effective_action(policy, field) == Retain {
match after.get(field.path) {
Some(other) => if other.value != field.value { safe = false }
None => safe = false
}
}
}
safe
}
///|
pub fn resource_sensitive_path_count(
document : ResourceDocument,
policy : ResourcePolicy,
) -> Int {
document.fields
.filter(fn(field) {
resource_any_path_matches(policy.sensitive_paths, field.path)
})
.length()
}
///|
pub fn resource_action_counts(document : ResourceDocument) -> Map[String, Int] {
let counts : Map[String, Int] = Map([])
for field in document.fields {
let key = resource_action_name(field.action)
counts[key] = counts.get_or_default(key, 0) + 1
}
counts
}
///|
pub fn resource_kind_counts(document : ResourceDocument) -> Map[String, Int] {
let counts : Map[String, Int] = Map([])
for field in document.fields {
let key = phi_kind_name(field.kind)
counts[key] = counts.get_or_default(key, 0) + 1
}
counts
}
///|
pub fn resource_paths_by_kind(
document : ResourceDocument,
kind : PhiKind,
) -> Array[String] {
document.fields
.filter(fn(field) { field.kind == kind })
.map(fn(field) { field.path })
}
///|
pub fn resource_empty_values(document : ResourceDocument) -> Array[String] {
document.fields
.filter(fn(field) { field.value.trim().is_empty() })
.map(fn(field) { field.path })
}
///|
pub fn resource_duplicate_paths(document : ResourceDocument) -> Array[String] {
let counts : Map[String, Int] = Map([])
for field in document.fields {
counts[field.path] = counts.get_or_default(field.path, 0) + 1
}
let result = []
for path, count in counts {
if count > 1 {
result.push(path)
}
}
result.sort()
result
}
///|
pub fn resource_deduplicate(document : ResourceDocument) -> ResourceDocument {
let result = document.copy()
let seen : Map[String, Unit] = Map([])
result.fields = result.fields.filter(fn(field) {
if seen.contains(field.path) {
false
} else {
seen[field.path] = ()
true
}
})
result
}
///|
pub fn resource_prefix_paths(
document : ResourceDocument,
prefix : String,
) -> ResourceDocument {
let result = document.copy()
result.fields = result.fields.map(fn(field) {
{ ..field, path: prefix + field.path }
})
result
}
///|
pub fn resource_select_paths(
document : ResourceDocument,
paths : Array[String],
) -> ResourceDocument {
let result = document.copy()
result.fields = result.fields.filter(fn(field) {
resource_any_path_matches(paths, field.path)
})
result
}
///|
pub fn resource_exclude_paths(
document : ResourceDocument,
paths : Array[String],
) -> ResourceDocument {
let result = document.copy()
result.fields = result.fields.filter(fn(field) {
!resource_any_path_matches(paths, field.path)
})
result
}