use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::lease::{Lease, LeaseError};
const DAY: i64 = 24 * 60 * 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AdmissionMode {
Open,
Knock,
Users,
Closed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Confirmation {
Accept,
Compare,
Scan,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AdmissionPath {
Knock,
Offer,
Scan,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PolicyError {
#[error("posted lease needs 0 < default <= max, got default {default}, max {max}")]
LeaseBounds { default: i64, max: i64 },
#[error("notice {notice} must be in (0, {default} / 2]")]
NoticePastMidpoint { notice: i64, default: i64 },
#[error("admission policy on '{kind}' names '{relation}', which is not a membership relation")]
NotMembership { kind: String, relation: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "RawPostedLease")]
pub struct PostedLease {
default: i64,
max: i64,
notice: i64,
}
#[derive(Deserialize)]
struct RawPostedLease {
default: i64,
max: i64,
notice: i64,
}
impl TryFrom<RawPostedLease> for PostedLease {
type Error = PolicyError;
fn try_from(r: RawPostedLease) -> Result<Self, PolicyError> {
Self::new(r.default, r.max, r.notice)
}
}
impl PostedLease {
pub fn new(default: i64, max: i64, notice: i64) -> Result<Self, PolicyError> {
if default <= 0 || default > max {
return Err(PolicyError::LeaseBounds { default, max });
}
if notice <= 0 || notice > default / 2 {
return Err(PolicyError::NoticePastMidpoint { notice, default });
}
Ok(Self { default, max, notice })
}
pub fn default_secs(&self) -> i64 {
self.default
}
pub fn max_secs(&self) -> i64 {
self.max
}
pub fn notice_secs(&self) -> i64 {
self.notice
}
fn lease_of(&self, iat: i64, length: i64) -> Result<Lease, LeaseError> {
let exp = iat.saturating_add(length);
Lease::new(iat, iat.saturating_add(self.notice.min(length / 2)), Some(exp))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PathLeases {
pub knock: PostedLease,
pub offer: PostedLease,
pub scan: PostedLease,
}
impl PathLeases {
pub fn seed() -> Self {
let month = PostedLease { default: 30 * DAY, max: 30 * DAY, notice: 7 * DAY };
Self { knock: PostedLease { default: 7 * DAY, max: 7 * DAY, notice: DAY }, offer: month, scan: month }
}
pub fn get(&self, path: AdmissionPath) -> &PostedLease {
match path {
AdmissionPath::Knock => &self.knock,
AdmissionPath::Offer => &self.offer,
AdmissionPath::Scan => &self.scan,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdmissionPolicy {
pub mode: AdmissionMode,
pub floor: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub min_confirmation: BTreeMap<String, Confirmation>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub admitters: BTreeSet<String>,
pub leases: PathLeases,
}
impl AdmissionPolicy {
pub fn new(mode: AdmissionMode, floor: impl Into<String>) -> Self {
Self {
mode,
floor: floor.into(),
min_confirmation: BTreeMap::new(),
admitters: BTreeSet::new(),
leases: PathLeases::seed(),
}
}
pub fn with_min_confirmation(mut self, relation: impl Into<String>, level: Confirmation) -> Self {
self.min_confirmation.insert(relation.into(), level);
self
}
pub fn with_admitters(mut self, relations: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.admitters.extend(relations.into_iter().map(Into::into));
self
}
pub fn with_leases(mut self, leases: PathLeases) -> Self {
self.leases = leases;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AdmitFacts<'a> {
pub relation: &'a str,
pub path: AdmissionPath,
pub confirmation: Option<Confirmation>,
pub requester_is_user: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LeaseRequest {
Length(i64),
Exact(Lease),
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AdmissionRefusal {
#[error("admission mode {0:?} permits no such offline admit")]
Mode(AdmissionMode),
#[error("a key with no Admit enters only at the floor relation {floor:?}")]
NotFloor { floor: String },
#[error("relation {relation:?} needs confirmation {needed:?}, got {got:?}")]
BelowConfirmation { relation: String, needed: Confirmation, got: Confirmation },
#[error("lease of {length}s exceeds the {path:?} maximum {max}s")]
LeaseOverMax { path: AdmissionPath, length: i64, max: i64 },
#[error("an Admit's lease must carry an exp after iat")]
LeaseUnbounded,
#[error(transparent)]
Lease(#[from] LeaseError),
}
pub fn evaluate_admit(
policy: Option<&AdmissionPolicy>,
admit: &AdmitFacts<'_>,
iat: i64,
lease: Option<LeaseRequest>,
) -> Result<Lease, AdmissionRefusal> {
let Some(policy) = policy else {
return Err(AdmissionRefusal::Mode(AdmissionMode::Closed));
};
match (policy.mode, admit.confirmation) {
(AdmissionMode::Closed, _) => return Err(AdmissionRefusal::Mode(AdmissionMode::Closed)),
(AdmissionMode::Users, _) if !admit.requester_is_user => {
return Err(AdmissionRefusal::Mode(AdmissionMode::Users));
}
(AdmissionMode::Open, None) if admit.relation != policy.floor => {
return Err(AdmissionRefusal::NotFloor { floor: policy.floor.clone() });
}
(AdmissionMode::Open, None) => {}
(mode, None) => return Err(AdmissionRefusal::Mode(mode)),
(_, Some(got)) => {
if let Some(&needed) = policy.min_confirmation.get(admit.relation) {
if got < needed {
return Err(AdmissionRefusal::BelowConfirmation {
relation: admit.relation.to_owned(),
needed,
got,
});
}
}
}
}
let terms = policy.leases.get(admit.path);
let over = |length: i64| AdmissionRefusal::LeaseOverMax { path: admit.path, length, max: terms.max };
match lease {
None => Ok(terms.lease_of(iat, terms.default)?),
Some(LeaseRequest::Length(length)) if length > terms.max => Err(over(length)),
Some(LeaseRequest::Length(length)) => Ok(terms.lease_of(iat, length)?),
Some(LeaseRequest::Exact(l)) => {
l.validate(iat)?;
let exp = l.exp().filter(|&e| e > iat).ok_or(AdmissionRefusal::LeaseUnbounded)?;
if exp - iat > terms.max {
return Err(over(exp - iat));
}
Ok(l)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const IAT: i64 = 1_000;
fn facts(relation: &str, confirmation: Option<Confirmation>, user: bool) -> AdmitFacts<'_> {
AdmitFacts { relation, path: AdmissionPath::Knock, confirmation, requester_is_user: user }
}
fn eval(p: Option<&AdmissionPolicy>, f: &AdmitFacts<'_>) -> Result<Lease, AdmissionRefusal> {
evaluate_admit(p, f, IAT, None)
}
#[test]
fn open_admits_any_key_at_the_floor_relation() {
let p = AdmissionPolicy::new(AdmissionMode::Open, "guest");
assert!(eval(Some(&p), &facts("guest", None, false)).is_ok());
assert_eq!(
eval(Some(&p), &facts("member", None, false)),
Err(AdmissionRefusal::NotFloor { floor: "guest".into() })
);
assert!(eval(Some(&p), &facts("member", Some(Confirmation::Accept), false)).is_ok());
}
#[test]
fn knock_requires_an_admit() {
let p = AdmissionPolicy::new(AdmissionMode::Knock, "guest");
assert_eq!(eval(Some(&p), &facts("guest", None, false)), Err(AdmissionRefusal::Mode(AdmissionMode::Knock)));
assert!(eval(Some(&p), &facts("guest", Some(Confirmation::Accept), false)).is_ok());
}
#[test]
fn users_makes_key_only_admits_inert() {
let p = AdmissionPolicy::new(AdmissionMode::Users, "guest");
let key_only = facts("guest", Some(Confirmation::Scan), false);
assert_eq!(eval(Some(&p), &key_only), Err(AdmissionRefusal::Mode(AdmissionMode::Users)));
assert!(eval(Some(&p), &facts("guest", Some(Confirmation::Accept), true)).is_ok());
}
#[test]
fn closed_and_no_policy_make_every_offline_admit_inert() {
let p = AdmissionPolicy::new(AdmissionMode::Closed, "guest");
for f in [facts("guest", None, false), facts("guest", Some(Confirmation::Scan), true)] {
assert_eq!(eval(Some(&p), &f), Err(AdmissionRefusal::Mode(AdmissionMode::Closed)));
assert_eq!(eval(None, &f), Err(AdmissionRefusal::Mode(AdmissionMode::Closed)));
}
}
#[test]
fn moving_the_dial_back_restores_admits() {
let admit = facts("guest", Some(Confirmation::Accept), false);
let mut p = AdmissionPolicy::new(AdmissionMode::Knock, "guest");
let lease = eval(Some(&p), &admit).unwrap();
let held = Some(LeaseRequest::Exact(lease));
p.mode = AdmissionMode::Users;
assert!(evaluate_admit(Some(&p), &admit, IAT, held).is_err());
p.mode = AdmissionMode::Closed;
assert!(evaluate_admit(Some(&p), &admit, IAT, held).is_err());
p.mode = AdmissionMode::Knock;
assert_eq!(evaluate_admit(Some(&p), &admit, IAT, held), Ok(lease));
}
#[test]
fn an_admit_below_the_minimum_confirmation_is_refused() {
let p = AdmissionPolicy::new(AdmissionMode::Knock, "guest").with_min_confirmation("member", Confirmation::Compare);
assert!(matches!(
eval(Some(&p), &facts("member", Some(Confirmation::Accept), false)),
Err(AdmissionRefusal::BelowConfirmation { needed: Confirmation::Compare, got: Confirmation::Accept, .. })
));
assert!(eval(Some(&p), &facts("member", Some(Confirmation::Scan), false)).is_ok());
assert!(eval(Some(&p), &facts("guest", Some(Confirmation::Accept), false)).is_ok());
}
#[test]
fn defaults_apply_when_no_lease_is_picked() {
let p = AdmissionPolicy::new(AdmissionMode::Knock, "guest");
let knock = eval(Some(&p), &facts("guest", Some(Confirmation::Accept), false)).unwrap();
assert_eq!((knock.refresh_after(), knock.exp()), (IAT + DAY, Some(IAT + 7 * DAY)));
let offer = AdmitFacts { path: AdmissionPath::Offer, ..facts("guest", Some(Confirmation::Accept), false) };
let l = evaluate_admit(Some(&p), &offer, IAT, None).unwrap();
assert_eq!((l.refresh_after(), l.exp()), (IAT + 7 * DAY, Some(IAT + 30 * DAY)));
let day = evaluate_admit(Some(&p), &facts("guest", Some(Confirmation::Accept), false), IAT, Some(LeaseRequest::Length(DAY)))
.unwrap();
assert_eq!((day.refresh_after(), day.exp()), (IAT + DAY / 2, Some(IAT + DAY)));
}
#[test]
fn a_lease_over_the_path_maximum_is_refused() {
let p = AdmissionPolicy::new(AdmissionMode::Knock, "guest");
let f = facts("guest", Some(Confirmation::Accept), false);
assert!(matches!(
evaluate_admit(Some(&p), &f, IAT, Some(LeaseRequest::Length(8 * DAY))),
Err(AdmissionRefusal::LeaseOverMax { path: AdmissionPath::Knock, max, .. }) if max == 7 * DAY
));
let long = Lease::new(IAT, IAT + DAY, Some(IAT + 30 * DAY)).unwrap();
assert!(matches!(
evaluate_admit(Some(&p), &f, IAT, Some(LeaseRequest::Exact(long))),
Err(AdmissionRefusal::LeaseOverMax { .. })
));
let unbounded = Lease::new(IAT, IAT + DAY, None).unwrap();
assert_eq!(
evaluate_admit(Some(&p), &f, IAT, Some(LeaseRequest::Exact(unbounded))),
Err(AdmissionRefusal::LeaseUnbounded)
);
}
#[test]
fn a_notice_past_the_midpoint_is_refused_on_build_and_on_read() {
assert_eq!(PostedLease::new(DAY, DAY, DAY), Err(PolicyError::NoticePastMidpoint { notice: DAY, default: DAY }));
assert_eq!(PostedLease::new(2 * DAY, DAY, 1), Err(PolicyError::LeaseBounds { default: 2 * DAY, max: DAY }));
let mut v = serde_json::to_value(AdmissionPolicy::new(AdmissionMode::Open, "guest")).unwrap();
v["leases"]["knock"]["notice"] = (5 * DAY).into();
assert!(serde_json::from_value::<AdmissionPolicy>(v).is_err());
}
#[test]
fn wire_round_trips() {
let p = AdmissionPolicy::new(AdmissionMode::Users, "guest").with_min_confirmation("member", Confirmation::Scan);
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["mode"], "users");
assert_eq!(v["min_confirmation"]["member"], "scan");
assert_eq!(v["leases"]["knock"], serde_json::json!({"default": 604800, "max": 604800, "notice": 86400}));
assert!(v.get("admitters").is_none(), "empty admitters stay off the wire");
let p = p.with_admitters(["admin"]);
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["admitters"], serde_json::json!(["admin"]));
assert_eq!(serde_json::from_value::<AdmissionPolicy>(v).unwrap(), p);
}
}