use chrono::{DateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use crate::acl::ApproveScope;
use super::create::CreateAclResultBody;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct StepUp {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approver: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub require: Option<String>,
}
impl StepUp {
fn is_empty(&self) -> bool {
self.approver.is_none() && self.require.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct Approve {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub all: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<String>,
}
impl Approve {
fn is_empty(&self) -> bool {
!self.all && self.scopes.is_empty()
}
pub fn to_scope(&self) -> ApproveScope {
if self.all {
ApproveScope::All
} else if self.scopes.is_empty() {
ApproveScope::None
} else {
ApproveScope::Contexts(self.scopes.clone())
}
}
pub fn from_scope(scope: &ApproveScope) -> Self {
match scope {
ApproveScope::None => Self::default(),
ApproveScope::All => Self {
all: true,
scopes: Vec::new(),
},
ApproveScope::Contexts(cs) => Self {
all: false,
scopes: cs.clone(),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct AclEntry {
pub subject: String,
pub role: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_keys: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step_up: Option<StepUp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approve: Option<Approve>,
}
fn to_rfc3339(epoch: u64) -> Option<DateTime<Utc>> {
i64::try_from(epoch)
.ok()
.and_then(|s| Utc.timestamp_opt(s, 0).single())
}
pub fn to_epoch(ts: DateTime<Utc>) -> u64 {
u64::try_from(ts.timestamp()).unwrap_or(0)
}
impl AclEntry {
pub fn from_result(r: &CreateAclResultBody) -> Self {
let step_up = StepUp {
approver: r.step_up_approver.clone(),
require: r.step_up_require.clone(),
};
let approve = Approve {
all: r.approve_all_contexts,
scopes: r.approve_contexts.clone(),
};
Self {
subject: r.did.clone(),
role: r.role.clone(),
scopes: r.allowed_contexts.clone(),
allowed_keys: r.allowed_keys.clone(),
label: r.label.clone(),
created_at: to_rfc3339(r.created_at),
created_by: Some(r.created_by.clone()),
updated_at: None,
updated_by: None,
expires_at: r.expires_at.and_then(to_rfc3339),
step_up: (!step_up.is_empty()).then_some(step_up),
approve: (!approve.is_empty()).then_some(approve),
}
}
pub fn step_up_approver(&self) -> Option<String> {
self.step_up.as_ref().and_then(|s| s.approver.clone())
}
pub fn step_up_require(&self) -> Option<String> {
self.step_up.as_ref().and_then(|s| s.require.clone())
}
pub fn approve_scope(&self) -> ApproveScope {
self.approve
.as_ref()
.map(Approve::to_scope)
.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_approve_and_step_up_are_omitted_not_emitted() {
let e = AclEntry {
subject: "did:key:z6MkA".into(),
role: "reader".into(),
scopes: vec![],
allowed_keys: None,
label: None,
created_at: None,
created_by: None,
updated_at: None,
updated_by: None,
expires_at: None,
step_up: None,
approve: None,
};
let v = serde_json::to_value(&e).unwrap();
assert!(v.get("approve").is_none(), "{v}");
assert!(v.get("stepUp").is_none(), "{v}");
assert!(v.get("scopes").is_none(), "empty scopes are omitted: {v}");
}
#[test]
fn absent_approve_confers_nothing() {
let e: AclEntry =
serde_json::from_value(serde_json::json!({"subject": "did:key:zA", "role": "admin"}))
.unwrap();
assert_eq!(e.approve_scope(), ApproveScope::None);
}
#[test]
fn approve_all_takes_precedence_over_scopes() {
let a = Approve {
all: true,
scopes: vec!["ctx-a".into()],
};
assert_eq!(a.to_scope(), ApproveScope::All);
}
#[test]
fn approve_scope_round_trips() {
for s in [
ApproveScope::None,
ApproveScope::All,
ApproveScope::Contexts(vec!["a".into(), "b".into()]),
] {
assert_eq!(Approve::from_scope(&s).to_scope(), s);
}
}
#[test]
fn expiry_round_trips_through_rfc3339() {
let epoch = 1_800_000_000u64;
let ts = to_rfc3339(epoch).expect("representable");
assert_eq!(to_epoch(ts), epoch);
assert_eq!(ts.to_rfc3339(), "2027-01-15T08:00:00+00:00");
}
#[test]
fn allowed_keys_empty_and_absent_both_survive_the_wire() {
let mut e: AclEntry =
serde_json::from_value(serde_json::json!({"subject": "did:key:zA", "role": "reader"}))
.unwrap();
assert_eq!(e.allowed_keys, None, "absent decodes to None (no filter)");
let v = serde_json::to_value(&e).unwrap();
assert!(
v.get("allowedKeys").is_none(),
"no filter emits nothing: {v}"
);
e.allowed_keys = Some(vec![]);
let v = serde_json::to_value(&e).unwrap();
assert_eq!(
v.get("allowedKeys"),
Some(&serde_json::json!([])),
"the empty filter must be emitted, not skipped: {v}"
);
let back: AclEntry = serde_json::from_value(v).unwrap();
assert_eq!(back.allowed_keys, Some(vec![]));
e.allowed_keys = Some(vec!["key-1".into()]);
let v = serde_json::to_value(&e).unwrap();
assert!(v.get("allowedKeys").is_some(), "{v}");
assert!(v.get("allowed_keys").is_none(), "{v}");
}
#[test]
fn wire_form_is_camel_case() {
let e = AclEntry {
subject: "did:key:zA".into(),
role: "admin".into(),
scopes: vec!["ctx".into()],
allowed_keys: None,
label: None,
created_at: None,
created_by: None,
updated_at: None,
updated_by: None,
expires_at: to_rfc3339(1_800_000_000),
step_up: Some(StepUp {
approver: Some("did:key:zB".into()),
require: Some("delegated".into()),
}),
approve: None,
};
let v = serde_json::to_value(&e).unwrap();
assert!(v.get("expiresAt").is_some(), "{v}");
assert!(v.get("stepUp").is_some(), "{v}");
assert!(v.get("allowed_contexts").is_none(), "{v}");
assert!(v.get("did").is_none(), "subject, not did: {v}");
}
}