use serde::{Deserialize, Serialize};
use crate::error::AppError;
use crate::store::KeyspaceHandle;
use super::session::now_epoch;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PendingStepUp {
pub challenge: String,
pub session_id: String,
pub subject: String,
#[serde(default)]
pub approver: String,
#[serde(default)]
pub approver_any: bool,
pub target_acr: String,
#[serde(default)]
pub acceptable_evidence: Vec<String>,
pub created_at: u64,
pub expires_at: u64,
}
pub mod op_class {
pub const ACL_GRANT: &str = "acl/grant";
pub const ACL_CHANGE_ROLE: &str = "acl/change-role";
pub const ACL_REVOKE: &str = "acl/revoke";
pub const ACL_SWAP_KEY: &str = "acl/swap-key";
pub const CONTEXT_DELETE: &str = "context/delete";
pub const KEY_REVOKE: &str = "key/revoke";
pub const ALL: &[&str] = &[
ACL_GRANT,
ACL_CHANGE_ROLE,
ACL_REVOKE,
ACL_SWAP_KEY,
CONTEXT_DELETE,
KEY_REVOKE,
];
pub fn is_recognized(operation: &str) -> bool {
operation == "*" || ALL.contains(&operation)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum StepUpMode {
#[default]
None,
#[serde(rename = "self")]
SelfApprove,
Delegated,
DelegatedAny,
}
impl StepUpMode {
fn rank(self) -> u8 {
match self {
StepUpMode::None => 0,
StepUpMode::SelfApprove => 1,
StepUpMode::DelegatedAny => 2,
StepUpMode::Delegated => 3,
}
}
pub fn requires_aal2(self) -> bool {
self != StepUpMode::None
}
pub fn strictest(self, other: StepUpMode) -> StepUpMode {
if other.rank() > self.rank() {
other
} else {
self
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StepUpFloor {
pub operation: String,
pub mode: StepUpMode,
#[serde(default)]
pub allow_aal1_if_non_escalating: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct StepUpPolicy {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub floors: Vec<StepUpFloor>,
}
impl StepUpPolicy {
pub fn floor_for(&self, operation: &str) -> StepUpMode {
self.floor_record(operation)
.map(|f| f.mode)
.unwrap_or(StepUpMode::None)
}
pub fn floor_record(&self, operation: &str) -> Option<&StepUpFloor> {
if !self.enabled {
return None;
}
self.floors
.iter()
.find(|f| f.operation == operation)
.or_else(|| self.floors.iter().find(|f| f.operation == "*"))
}
}
fn step_up_key(challenge: &str) -> String {
format!("stepup:{challenge}")
}
#[derive(Debug, PartialEq)]
pub enum ConsumeOutcome {
NotFound,
Expired,
Found(Box<PendingStepUp>),
}
pub async fn store_pending_step_up(
sessions: &KeyspaceHandle,
pending: &PendingStepUp,
) -> Result<(), AppError> {
sessions
.insert(step_up_key(&pending.challenge), pending)
.await
}
pub async fn get_pending_step_up(
sessions: &KeyspaceHandle,
challenge: &str,
) -> Result<Option<PendingStepUp>, AppError> {
sessions.get(step_up_key(challenge)).await
}
pub async fn consume_pending_step_up(
sessions: &KeyspaceHandle,
challenge: &str,
now: u64,
) -> Result<ConsumeOutcome, AppError> {
let key = step_up_key(challenge);
let Some(pending): Option<PendingStepUp> = sessions.get(key.clone()).await? else {
return Ok(ConsumeOutcome::NotFound);
};
sessions.remove(key).await?;
if now >= pending.expires_at {
return Ok(ConsumeOutcome::Expired);
}
Ok(ConsumeOutcome::Found(Box::new(pending)))
}
pub fn new_pending_step_up(
challenge: impl Into<String>,
session_id: impl Into<String>,
subject: impl Into<String>,
approver: impl Into<String>,
approver_any: bool,
target_acr: impl Into<String>,
acceptable_evidence: Vec<String>,
ttl_secs: u64,
) -> PendingStepUp {
let created_at = now_epoch();
PendingStepUp {
challenge: challenge.into(),
session_id: session_id.into(),
subject: subject.into(),
approver: approver.into(),
approver_any,
target_acr: target_acr.into(),
acceptable_evidence,
created_at,
expires_at: created_at.saturating_add(ttl_secs),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::StoreConfig;
use crate::store::Store;
async fn ks() -> KeyspaceHandle {
let dir = tempfile::tempdir().expect("tempdir");
let dir = Box::leak(Box::new(dir));
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("open store");
store.keyspace("sessions").expect("keyspace")
}
fn sample(challenge: &str, expires_at: u64) -> PendingStepUp {
PendingStepUp {
challenge: challenge.to_string(),
session_id: "sess-1".to_string(),
subject: "did:key:zHolder".to_string(),
approver: "did:key:zHolder".to_string(),
approver_any: false,
target_acr: "aal2".to_string(),
acceptable_evidence: vec!["did-signed".into(), "webauthn".into()],
created_at: 1000,
expires_at,
}
}
#[tokio::test]
async fn round_trips_and_consumes_once() {
let ks = ks().await;
let p = sample("VHJhbnNmZXJDb25maXJtTm9uY2VYWQ", now_epoch() + 300);
store_pending_step_up(&ks, &p).await.unwrap();
assert_eq!(
get_pending_step_up(&ks, &p.challenge).await.unwrap(),
Some(p.clone())
);
match consume_pending_step_up(&ks, &p.challenge, now_epoch())
.await
.unwrap()
{
ConsumeOutcome::Found(found) => assert_eq!(*found, p),
other => panic!("expected Found, got {other:?}"),
}
assert_eq!(
consume_pending_step_up(&ks, &p.challenge, now_epoch())
.await
.unwrap(),
ConsumeOutcome::NotFound
);
}
#[tokio::test]
async fn unknown_challenge_is_not_found() {
let ks = ks().await;
assert_eq!(
consume_pending_step_up(&ks, "no-such-challenge", now_epoch())
.await
.unwrap(),
ConsumeOutcome::NotFound
);
}
#[tokio::test]
async fn expired_challenge_is_consumed_and_reported_expired() {
let ks = ks().await;
let p = sample("RXhwaXJlZENoYWxsZW5nZVZhbHVlWA", 1000); store_pending_step_up(&ks, &p).await.unwrap();
assert_eq!(
consume_pending_step_up(&ks, &p.challenge, now_epoch())
.await
.unwrap(),
ConsumeOutcome::Expired
);
assert_eq!(get_pending_step_up(&ks, &p.challenge).await.unwrap(), None);
}
#[test]
fn new_pending_sets_expiry() {
let p = new_pending_step_up(
"VHJhbnNmZXJDb25maXJtTm9uY2VYWQ",
"sess-1",
"did:key:zHolder",
"did:key:zApprover",
false,
"aal2",
vec!["webauthn".into()],
300,
);
assert_eq!(p.expires_at, p.created_at + 300);
assert_eq!(p.target_acr, "aal2");
assert_eq!(p.approver, "did:key:zApprover");
assert!(!p.approver_any);
}
#[test]
fn legacy_record_without_approver_defaults_empty() {
let legacy = r#"{
"challenge":"VHJhbnNmZXJDb25maXJtTm9uY2VYWQ",
"session_id":"sess-1",
"subject":"did:key:zHolder",
"target_acr":"aal2",
"acceptable_evidence":["did-signed"],
"created_at":1000,
"expires_at":2000
}"#;
let p: PendingStepUp = serde_json::from_str(legacy).expect("legacy record deserializes");
assert_eq!(p.approver, "");
assert_eq!(p.subject, "did:key:zHolder");
}
fn floor(op: &str, mode: StepUpMode) -> StepUpFloor {
StepUpFloor {
operation: op.to_string(),
mode,
allow_aal1_if_non_escalating: false,
}
}
#[test]
fn default_policy_is_disabled_and_never_gates() {
let p = StepUpPolicy::default();
assert!(!p.enabled);
assert_eq!(p.floor_for("acl/grant"), StepUpMode::None);
assert_eq!(p.floor_for("*"), StepUpMode::None);
assert!(!p.floor_for("anything").requires_aal2());
}
#[test]
fn disabled_policy_ignores_configured_floors() {
let p = StepUpPolicy {
enabled: false,
floors: vec![floor("*", StepUpMode::Delegated)],
};
assert_eq!(p.floor_for("acl/grant"), StepUpMode::None);
assert!(p.floor_record("acl/grant").is_none());
}
#[test]
fn enabled_resolves_exact_then_catch_all() {
let p = StepUpPolicy {
enabled: true,
floors: vec![
floor("*", StepUpMode::SelfApprove),
floor("acl/grant", StepUpMode::Delegated),
],
};
assert_eq!(p.floor_for("acl/grant"), StepUpMode::Delegated);
assert_eq!(p.floor_for("context/delete"), StepUpMode::SelfApprove);
}
#[test]
fn enabled_without_catch_all_is_none_for_unlisted() {
let p = StepUpPolicy {
enabled: true,
floors: vec![floor("acl/grant", StepUpMode::Delegated)],
};
assert_eq!(p.floor_for("acl/swap-key"), StepUpMode::None);
assert_eq!(p.floor_for("acl/grant"), StepUpMode::Delegated);
}
#[test]
fn mode_strictness_is_additive() {
assert_eq!(
StepUpMode::SelfApprove.strictest(StepUpMode::Delegated),
StepUpMode::Delegated
);
assert_eq!(
StepUpMode::Delegated.strictest(StepUpMode::SelfApprove),
StepUpMode::Delegated
);
assert_eq!(
StepUpMode::None.strictest(StepUpMode::SelfApprove),
StepUpMode::SelfApprove
);
assert!(!StepUpMode::None.requires_aal2());
assert!(StepUpMode::SelfApprove.requires_aal2());
assert!(StepUpMode::DelegatedAny.requires_aal2());
}
#[test]
fn mode_serde_uses_spec_wire_tokens() {
assert_eq!(
serde_json::to_string(&StepUpMode::SelfApprove).unwrap(),
"\"self\""
);
assert_eq!(
serde_json::to_string(&StepUpMode::DelegatedAny).unwrap(),
"\"delegated-any\""
);
assert_eq!(
serde_json::from_str::<StepUpMode>("\"none\"").unwrap(),
StepUpMode::None
);
}
}