use crate::base::AccessGroupRef;
use crate::rm::common::{LocatableAttrs, impl_locatable};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Operation {
Read,
Write,
Delete,
Audit,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AccessRequest<'a> {
pub operation: Operation,
pub groups: &'a [String],
pub is_subject: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Permit,
Deny(DenyReason),
}
impl Decision {
#[must_use]
pub fn is_permit(&self) -> bool {
matches!(self, Self::Permit)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DenyReason {
NotInPermittedGroup,
NoSettingsRecorded,
SchemeNotImplemented {
scheme: String,
},
RecordNotModifiable,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct GroupSettings {
#[serde(skip_serializing_if = "Vec::is_empty", default)]
read_groups: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
write_groups: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
delete_groups: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
audit_groups: Vec<String>,
#[serde(default)]
subject_may_read: bool,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
group_refs: Vec<AccessGroupRef>,
}
impl GroupSettings {
pub const SCHEME: &'static str = "openehr-rs.group-list.v1";
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn permit(mut self, operation: Operation, group: impl Into<String>) -> Self {
let group = group.into();
match operation {
Operation::Read => self.read_groups.push(group),
Operation::Write => self.write_groups.push(group),
Operation::Delete => self.delete_groups.push(group),
Operation::Audit => self.audit_groups.push(group),
}
self
}
#[must_use]
pub fn permit_subject_read(mut self) -> Self {
self.subject_may_read = true;
self
}
#[must_use]
pub fn with_group_ref(mut self, group_ref: AccessGroupRef) -> Self {
self.group_refs.push(group_ref);
self
}
#[must_use]
pub fn groups_for(&self, operation: Operation) -> &[String] {
match operation {
Operation::Read => &self.read_groups,
Operation::Write => &self.write_groups,
Operation::Delete => &self.delete_groups,
Operation::Audit => &self.audit_groups,
}
}
#[must_use]
pub fn decide(&self, request: &AccessRequest<'_>) -> Decision {
if request.is_subject && self.subject_may_read && request.operation == Operation::Read {
return Decision::Permit;
}
let permitted = self.groups_for(request.operation);
if request.groups.iter().any(|g| permitted.contains(g)) {
Decision::Permit
} else {
Decision::Deny(DenyReason::NotInPermittedGroup)
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OpaqueSettings {
scheme: String,
#[serde(flatten)]
settings: serde_json::Map<String, serde_json::Value>,
}
impl OpaqueSettings {
#[must_use]
pub fn scheme(&self) -> &str {
&self.scheme
}
#[must_use]
pub fn settings(&self) -> &serde_json::Map<String, serde_json::Value> {
&self.settings
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum AccessControlSettings {
Groups(GroupSettings),
Opaque(OpaqueSettings),
}
impl Serialize for AccessControlSettings {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
Self::Groups(g) => {
#[derive(Serialize)]
struct Tagged<'a> {
scheme: &'static str,
#[serde(flatten)]
inner: &'a GroupSettings,
}
Tagged {
scheme: GroupSettings::SCHEME,
inner: g,
}
.serialize(s)
}
Self::Opaque(o) => o.serialize(s),
}
}
}
impl<'de> Deserialize<'de> for AccessControlSettings {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::Error as _;
let mut map = serde_json::Map::<String, serde_json::Value>::deserialize(d)?;
let scheme = map
.get("scheme")
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
match scheme.as_deref() {
None | Some(GroupSettings::SCHEME) => {
map.remove("scheme");
serde_json::from_value(serde_json::Value::Object(map))
.map(Self::Groups)
.map_err(D::Error::custom)
}
Some(_) => serde_json::from_value(serde_json::Value::Object(map))
.map(Self::Opaque)
.map_err(D::Error::custom),
}
}
}
impl AccessControlSettings {
#[must_use]
pub fn scheme(&self) -> &str {
match self {
Self::Groups(_) => GroupSettings::SCHEME,
Self::Opaque(o) => o.scheme(),
}
}
#[must_use]
pub fn decide(&self, request: &AccessRequest<'_>) -> Decision {
match self {
Self::Groups(g) => g.decide(request),
Self::Opaque(o) => Decision::Deny(DenyReason::SchemeNotImplemented {
scheme: o.scheme().to_owned(),
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EhrAccess {
#[serde(flatten)]
locatable: LocatableAttrs,
#[serde(skip_serializing_if = "Option::is_none", default)]
settings: Option<AccessControlSettings>,
}
impl_locatable!(EhrAccess, "EHR_ACCESS");
impl EhrAccess {
#[must_use]
pub fn new(locatable: LocatableAttrs) -> Self {
Self {
locatable,
settings: None,
}
}
#[must_use]
pub fn with_settings(mut self, settings: AccessControlSettings) -> Self {
self.settings = Some(settings);
self
}
#[must_use]
pub fn settings(&self) -> Option<&AccessControlSettings> {
self.settings.as_ref()
}
#[must_use]
pub fn scheme(&self) -> Option<&str> {
self.settings.as_ref().map(AccessControlSettings::scheme)
}
#[must_use]
pub fn decide(&self, request: &AccessRequest<'_>) -> Decision {
match &self.settings {
None => Decision::Deny(DenyReason::NoSettingsRecorded),
Some(settings) => settings.decide(request),
}
}
}
impl From<GroupSettings> for AccessControlSettings {
fn from(v: GroupSettings) -> Self {
Self::Groups(v)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rm::common::LocatableAttrs;
fn attrs() -> LocatableAttrs {
LocatableAttrs::named("EHR Access", "openEHR-EHR-EHR_ACCESS.generic.v1").unwrap()
}
#[test]
fn nothing_recorded_denies_rather_than_permits() {
let access = EhrAccess::new(attrs());
let request = AccessRequest {
operation: Operation::Read,
groups: &["care-team".to_string()],
is_subject: false,
};
assert_eq!(
access.decide(&request),
Decision::Deny(DenyReason::NoSettingsRecorded)
);
}
#[test]
fn an_unimplemented_scheme_denies_and_names_itself() {
let opaque: AccessControlSettings = serde_json::from_str(
r#"{"scheme":"nl.nictiz.opt-out.v2","register":"national","withdrawn":false}"#,
)
.unwrap();
assert_eq!(opaque.scheme(), "nl.nictiz.opt-out.v2");
let request = AccessRequest {
operation: Operation::Read,
groups: &[],
is_subject: false,
};
assert_eq!(
opaque.decide(&request),
Decision::Deny(DenyReason::SchemeNotImplemented {
scheme: "nl.nictiz.opt-out.v2".to_owned()
})
);
}
#[test]
fn an_unimplemented_scheme_round_trips_unchanged() {
let json = r#"{"register":"national","scheme":"nl.nictiz.opt-out.v2","withdrawn":true}"#;
let settings: AccessControlSettings = serde_json::from_str(json).unwrap();
let back = crate::security::canonical::to_canonical_string(&settings).unwrap();
assert_eq!(back, json);
}
#[test]
fn operations_are_separately_permitted() {
let settings = GroupSettings::new()
.permit(Operation::Read, "care-team")
.permit(Operation::Audit, "information-governance");
let ig = ["information-governance".to_string()];
let care = ["care-team".to_string()];
let permitted = |groups: &[String], op| {
settings
.decide(&AccessRequest {
operation: op,
groups,
is_subject: false,
})
.is_permit()
};
assert!(permitted(&care, Operation::Read));
assert!(!permitted(&care, Operation::Write));
assert!(permitted(&ig, Operation::Audit));
assert!(!permitted(&ig, Operation::Read));
}
#[test]
fn the_subject_reads_only_when_the_policy_says_so() {
let settings = GroupSettings::new();
let request = AccessRequest {
operation: Operation::Read,
groups: &[],
is_subject: true,
};
assert!(!settings.decide(&request).is_permit());
assert!(settings.permit_subject_read().decide(&request).is_permit());
}
#[test]
fn subject_read_permission_does_not_leak_into_write() {
let settings = GroupSettings::new().permit_subject_read();
let write = AccessRequest {
operation: Operation::Write,
groups: &[],
is_subject: true,
};
assert!(!settings.decide(&write).is_permit());
}
}