use lgwks_std::json::{Deserialize, Serialize};
use std::borrow::Cow;
use std::fmt;
use std::time::Duration;
use super::clock::Clock;
use super::error::BotError;
use super::gate::GrantSet;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde")]
pub struct Cap(Cow<'static, str>);
impl Cap {
pub const NET: &str = "bot.net";
pub const FS: &str = "bot.fs";
pub const SYS: &str = "bot.sys";
pub const NOTIFY: &str = "bot.notify";
pub fn new(name: impl Into<Cow<'static, str>>) -> Self {
Self(name.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_ref()
}
#[must_use]
pub fn net() -> Self {
Self::new(Self::NET)
}
#[must_use]
pub fn fs() -> Self {
Self::new(Self::FS)
}
#[must_use]
pub fn sys() -> Self {
Self::new(Self::SYS)
}
#[must_use]
pub fn notify() -> Self {
Self::new(Self::NOTIFY)
}
}
impl fmt::Display for Cap {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Demand {
domain: String,
}
impl Demand {
#[must_use]
pub fn new(domain: impl Into<String>) -> Self {
Self {
domain: domain.into(),
}
}
#[must_use]
pub fn domain(&self) -> &str {
&self.domain
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Shortage {
required: Cap,
demand: Option<Demand>,
}
impl Shortage {
pub(crate) fn new(required: Cap, demand: Option<Demand>) -> Self {
Self { required, demand }
}
#[must_use]
pub fn required(&self) -> &Cap {
&self.required
}
#[must_use]
pub fn demand(&self) -> Option<&Demand> {
self.demand.as_ref()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Deficit {
first: Shortage,
rest: Vec<Shortage>,
}
impl Deficit {
pub(crate) fn from_shortages(shortages: Vec<Shortage>) -> Option<Self> {
let mut remaining = shortages.into_iter();
let first = remaining.next()?;
Some(Self::new(first, remaining.collect()))
}
pub(crate) fn new(first: Shortage, rest: Vec<Shortage>) -> Self {
Self { first, rest }
}
pub fn shortages(&self) -> impl Iterator<Item = &Shortage> {
std::iter::once(&self.first).chain(self.rest.iter())
}
#[must_use]
pub fn len(&self) -> usize {
self.rest.len().saturating_add(1)
}
#[must_use]
pub fn is_empty(&self) -> bool {
false
}
#[must_use]
pub fn first(&self) -> &Shortage {
&self.first
}
#[must_use]
pub fn to_grant_set(&self) -> GrantSet {
self.shortages().fold(GrantSet::empty(), |set, shortage| {
set.grant(shortage.required.clone())
})
}
}
pub(crate) fn uncovered<F>(required: &[Cap], satisfied: F, demand: Option<&Demand>) -> Vec<Shortage>
where
F: Fn(&Cap) -> bool,
{
let mut shortages: Vec<Shortage> = Vec::new();
for cap in required {
if satisfied(cap) {
continue;
}
if shortages.iter().any(|shortage| shortage.required == *cap) {
continue;
}
shortages.push(Shortage::new(cap.clone(), demand.cloned()));
}
shortages
}
#[derive(Debug, Clone)]
pub struct Auth {
covers: Vec<Cap>,
lease: Option<Lease>,
}
#[derive(Debug, Clone)]
struct Lease {
clock: Clock,
expires_at: Duration,
}
impl Auth {
pub(crate) fn new(mut caps: Vec<Cap>) -> Self {
caps.sort_unstable();
caps.dedup();
Self {
covers: caps,
lease: None,
}
}
pub(crate) fn with_lease(mut self, clock: Clock, ttl: Duration) -> Self {
self.lease = Some(Lease {
expires_at: clock.now().saturating_add(ttl),
clock,
});
self
}
#[must_use]
pub fn expires_at(&self) -> Option<Duration> {
self.lease.as_ref().map(|lease| lease.expires_at)
}
#[must_use]
pub fn remaining(&self) -> Option<Duration> {
let lease = self.lease.as_ref()?;
Some(lease.expires_at.saturating_sub(lease.clock.now()))
}
#[must_use]
pub fn is_expired(&self) -> bool {
self.lease
.as_ref()
.is_some_and(|lease| lease.clock.now() >= lease.expires_at)
}
#[must_use]
pub fn covers(&self) -> &[Cap] {
&self.covers
}
#[must_use]
pub fn covers_cap(&self, cap: &Cap) -> bool {
self.covers.binary_search(cap).is_ok()
}
#[must_use]
pub fn uncovered(&self, required: &[Cap]) -> Vec<Shortage> {
uncovered(required, |cap| self.covers_cap(cap), None)
}
pub fn check(&self, required: &[Cap]) -> Result<(), BotError> {
self.check_expiry()?;
match Deficit::from_shortages(self.uncovered(required)) {
Some(deficit) => Err(BotError::CapabilityDenied { deficit }),
None => Ok(()),
}
}
fn check_expiry(&self) -> Result<(), BotError> {
let Some(lease) = self.lease.as_ref() else {
return Ok(());
};
let now = lease.clock.now();
if now < lease.expires_at {
return Ok(());
}
let refusal = Err(BotError::CredentialExpired {
capabilities: self.covers.clone(),
expired_at: lease.expires_at,
now,
});
lgwks_std::trace::debug!(
error = ?refusal.as_ref().err(),
"check_expiry: returning an error to the caller"
);
refusal
}
}
#[must_use]
pub const fn is_credential_status(status: u16) -> bool {
matches!(status, 401 | 403 | 404)
}
#[must_use]
pub fn upstream_credential_rejection(domain: &str, status: u16, required: &[Cap]) -> BotError {
BotError::CredentialRejected {
domain: domain.to_owned(),
status,
needs: crate::spec::NeedSet::expired_credentials(domain, required),
}
}
#[cfg(test)]
mod tests {
use super::{Auth, Cap, Deficit, Demand};
use crate::error::{BotError, DispatchCertainty};
use crate::gate::GrantSet;
fn failed(cause: impl Into<String>) -> BotError {
BotError::DomainError {
domain: "cap::tests".into(),
certainty: DispatchCertainty::NotDelivered,
cause: cause.into(),
}
}
fn caps(names: &[&'static str]) -> Vec<Cap> {
names.iter().map(|name| Cap::new(*name)).collect()
}
fn names(deficit: &Deficit) -> Vec<&str> {
deficit
.shortages()
.map(|shortage| shortage.required().as_str())
.collect()
}
#[test]
fn a_check_names_every_uncovered_requirement_at_once() -> Result<(), BotError> {
let proof = Auth::new(caps(&[Cap::NET]));
let required = caps(&[Cap::NET, Cap::FS, Cap::SYS]);
match proof.check(&required) {
Err(BotError::CapabilityDenied { deficit }) => {
assert_eq!(
names(&deficit),
vec![Cap::FS, Cap::SYS],
"the deficit must carry every uncovered requirement, in declaration order: \
{deficit}"
);
assert_eq!(deficit.len(), 2);
assert_eq!(
deficit.first().required(),
&Cap::fs(),
"first is the first one, not an arbitrary one: {deficit}"
);
Ok(())
}
other => Err(failed(format!(
"a proof covering `bot.net` must not admit `bot.fs` or `bot.sys`: {other:?}"
))),
}
}
#[test]
fn a_requirement_declared_twice_is_reported_once() -> Result<(), BotError> {
let proof = Auth::new(Vec::new());
match proof.check(&caps(&[Cap::NET, Cap::NET, Cap::FS])) {
Err(BotError::CapabilityDenied { deficit }) => {
assert_eq!(
names(&deficit),
vec![Cap::NET, Cap::FS],
"a domain listing one capability twice states one requirement: {deficit}"
);
assert_eq!(deficit.len(), 2, "and `len` must count requirements");
Ok(())
}
other => Err(failed(format!("an empty proof covers nothing: {other:?}"))),
}
}
#[test]
fn the_deficit_derives_the_grant_set_that_repairs_it() -> Result<(), BotError> {
let proof = Auth::new(caps(&[Cap::NET]));
let required = caps(&[Cap::NET, Cap::FS, Cap::SYS]);
match proof.check(&required) {
Err(BotError::CapabilityDenied { deficit }) => {
let missing: Vec<Cap> = deficit
.shortages()
.map(|shortage| shortage.required().clone())
.collect();
let repair = deficit.to_grant_set();
assert!(
repair.admit(&missing).is_ok(),
"the derived set must admit every requirement the deficit named: {deficit}"
);
assert!(
!repair.grants(&Cap::net()),
"a deficit is the shortfall, not a restatement of the requirement: \
`bot.net` was already covered, so it is not in the repair"
);
let held = GrantSet::empty().grant(Cap::net());
let closed = missing
.iter()
.fold(held.clone(), |set, cap| set.grant(cap.clone()));
assert!(
closed.admit(&required).is_ok(),
"the deficit's set folded into the held set must close the requirement"
);
assert!(
held.admit(&required).is_err(),
"control: the held set alone must not, or the test proves nothing"
);
assert!(
closed
.grant(Cap::net())
.uncovered(&required, &Demand::new("test::domain"))
.is_empty(),
"re-granting a capability the set already holds changes nothing"
);
Ok(())
}
other => Err(failed(format!(
"expected a denial for two uncovered capabilities: {other:?}"
))),
}
}
#[test]
fn a_proof_holds_its_capabilities_sorted_and_deduplicated() {
let proof = Auth::new(caps(&[Cap::SYS, Cap::NET, Cap::SYS, Cap::FS]));
assert_eq!(
proof.covers(),
caps(&[Cap::FS, Cap::NET, Cap::SYS]).as_slice(),
"a proof is a set: name-ordered, each capability once"
);
assert!(
proof.covers_cap(&Cap::net()),
"membership answers for a held cap"
);
assert!(
!proof.covers_cap(&Cap::notify()),
"and denies one it does not hold"
);
}
#[test]
fn the_proof_and_the_gate_name_the_same_shortfall() {
let required = caps(&[Cap::NET, Cap::FS]);
let proof = Auth::new(Vec::new());
let gate = GrantSet::empty();
let from_proof = proof.uncovered(&required);
let from_gate = gate.uncovered(&required, &Demand::new("test::domain"));
let proof_names: Vec<&str> = from_proof
.iter()
.map(|shortage| shortage.required().as_str())
.collect();
let gate_names: Vec<&str> = from_gate
.iter()
.map(|shortage| shortage.required().as_str())
.collect();
assert_eq!(
proof_names, gate_names,
"the two checks must agree on which requirements are unmet"
);
assert!(
from_proof
.iter()
.all(|shortage| shortage.demand().is_none()),
"a bare check is handed a capability list and no caller, so it names no domain"
);
assert_eq!(
from_gate
.first()
.and_then(|shortage| shortage.demand())
.map(Demand::domain),
Some("test::domain"),
"and the gate, which was told one, must carry it"
);
}
#[test]
fn a_borrowed_and_an_owned_spelling_are_one_capability() {
let borrowed = Cap::net();
let owned = Cap::new(String::from(Cap::NET));
assert!(matches!(borrowed.0, std::borrow::Cow::Borrowed(_)));
assert!(matches!(owned.0, std::borrow::Cow::Owned(_)));
assert_eq!(borrowed, owned, "the two spellings name one capability");
assert_eq!(Cap::net().cmp(&owned), std::cmp::Ordering::Equal);
assert!(
GrantSet::empty().grant(borrowed).grants(&owned),
"a grant made with a constant must cover a capability read from a spec"
);
assert!(
GrantSet::empty().grant(owned).grants(&Cap::net()),
"and the reverse, or the gate would depend on which side did the naming"
);
}
#[test]
fn a_covered_requirement_is_admitted() -> Result<(), BotError> {
let proof = Auth::new(caps(&[Cap::NET, Cap::FS]));
proof.check(&caps(&[Cap::FS, Cap::NET]))?;
assert!(
proof.uncovered(&caps(&[Cap::NET, Cap::FS])).is_empty(),
"the total form and the Result form must agree on the empty case"
);
Ok(())
}
}