1use serde::{Deserialize, Serialize};
15
16use crate::error::AppError;
17use crate::store::KeyspaceHandle;
18
19use super::session::now_epoch;
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct PendingStepUp {
24 pub challenge: String,
27 pub session_id: String,
29 pub subject: String,
32 #[serde(default)]
44 pub approver: String,
45 #[serde(default)]
52 pub approver_any: bool,
53 pub target_acr: String,
56 #[serde(default)]
59 pub acceptable_evidence: Vec<String>,
60 pub created_at: u64,
61 pub expires_at: u64,
63}
64
65pub mod op_class {
72 pub const ACL_GRANT: &str = "acl/grant";
73 pub const ACL_CHANGE_ROLE: &str = "acl/change-role";
74 pub const ACL_REVOKE: &str = "acl/revoke";
75 pub const ACL_SWAP_KEY: &str = "acl/swap-key";
76 pub const CONTEXT_DELETE: &str = "context/delete";
77 pub const KEY_REVOKE: &str = "key/revoke";
78 pub const VAULT_RELEASE: &str = "vault/release";
80 pub const VAULT_PROXY_LOGIN: &str = "vault/proxy-login";
83 pub const VAULT_SIGN_TRUST_TASK: &str = "vault/sign-trust-task";
86 pub const CREDENTIALS_ISSUE: &str = "credentials/issue";
89 pub const CREDENTIALS_REVOKE: &str = "credentials/revoke";
92
93 pub const ALL: &[&str] = &[
95 ACL_GRANT,
96 ACL_CHANGE_ROLE,
97 ACL_REVOKE,
98 ACL_SWAP_KEY,
99 CONTEXT_DELETE,
100 KEY_REVOKE,
101 VAULT_RELEASE,
102 VAULT_PROXY_LOGIN,
103 VAULT_SIGN_TRUST_TASK,
104 CREDENTIALS_ISSUE,
105 CREDENTIALS_REVOKE,
106 ];
107
108 pub fn is_recognized(operation: &str) -> bool {
111 operation == "*" || ALL.contains(&operation)
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
120#[serde(rename_all = "kebab-case")]
121pub enum StepUpMode {
122 #[default]
124 None,
125 #[serde(rename = "self")]
127 SelfApprove,
128 Delegated,
131 DelegatedAny,
133}
134
135impl StepUpMode {
136 fn rank(self) -> u8 {
138 match self {
139 StepUpMode::None => 0,
140 StepUpMode::SelfApprove => 1,
141 StepUpMode::DelegatedAny => 2,
142 StepUpMode::Delegated => 3,
143 }
144 }
145
146 pub fn requires_aal2(self) -> bool {
148 self != StepUpMode::None
149 }
150
151 pub fn strictest(self, other: StepUpMode) -> StepUpMode {
154 if other.rank() > self.rank() {
155 other
156 } else {
157 self
158 }
159 }
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
165pub struct StepUpFloor {
166 pub operation: String,
170 pub mode: StepUpMode,
172 #[serde(default)]
176 pub allow_aal1_if_non_escalating: bool,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
187pub struct StepUpPolicy {
188 #[serde(default)]
191 pub enabled: bool,
192 #[serde(default)]
195 pub floors: Vec<StepUpFloor>,
196}
197
198impl StepUpPolicy {
199 pub fn floor_for(&self, operation: &str) -> StepUpMode {
203 self.floor_record(operation)
204 .map(|f| f.mode)
205 .unwrap_or(StepUpMode::None)
206 }
207
208 pub fn floor_record(&self, operation: &str) -> Option<&StepUpFloor> {
212 if !self.enabled {
213 return None;
214 }
215 self.floors
216 .iter()
217 .find(|f| f.operation == operation)
218 .or_else(|| self.floors.iter().find(|f| f.operation == "*"))
219 }
220}
221
222fn step_up_key(challenge: &str) -> String {
223 format!("stepup:{challenge}")
224}
225
226#[derive(Debug, PartialEq)]
228pub enum ConsumeOutcome {
229 NotFound,
231 Expired,
234 Found(Box<PendingStepUp>),
236}
237
238pub async fn store_pending_step_up(
240 sessions: &KeyspaceHandle,
241 pending: &PendingStepUp,
242) -> Result<(), AppError> {
243 sessions
244 .insert(step_up_key(&pending.challenge), pending)
245 .await
246}
247
248pub async fn get_pending_step_up(
252 sessions: &KeyspaceHandle,
253 challenge: &str,
254) -> Result<Option<PendingStepUp>, AppError> {
255 sessions.get(step_up_key(challenge)).await
256}
257
258pub async fn consume_pending_step_up(
267 sessions: &KeyspaceHandle,
268 challenge: &str,
269 now: u64,
270) -> Result<ConsumeOutcome, AppError> {
271 let key = step_up_key(challenge);
272 let Some(pending): Option<PendingStepUp> = sessions.get(key.clone()).await? else {
273 return Ok(ConsumeOutcome::NotFound);
274 };
275 sessions.remove(key).await?;
278 if now >= pending.expires_at {
279 return Ok(ConsumeOutcome::Expired);
280 }
281 Ok(ConsumeOutcome::Found(Box::new(pending)))
282}
283
284pub fn new_pending_step_up(
286 challenge: impl Into<String>,
287 session_id: impl Into<String>,
288 subject: impl Into<String>,
289 approver: impl Into<String>,
290 approver_any: bool,
291 target_acr: impl Into<String>,
292 acceptable_evidence: Vec<String>,
293 ttl_secs: u64,
294) -> PendingStepUp {
295 let created_at = now_epoch();
296 PendingStepUp {
297 challenge: challenge.into(),
298 session_id: session_id.into(),
299 subject: subject.into(),
300 approver: approver.into(),
301 approver_any,
302 target_acr: target_acr.into(),
303 acceptable_evidence,
304 created_at,
305 expires_at: created_at.saturating_add(ttl_secs),
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use crate::config::StoreConfig;
313 use crate::store::Store;
314
315 #[test]
316 fn vault_op_classes_are_recognized_floor_targets() {
317 for op in [
321 op_class::VAULT_RELEASE,
322 op_class::VAULT_PROXY_LOGIN,
323 op_class::VAULT_SIGN_TRUST_TASK,
324 ] {
325 assert!(
326 op_class::is_recognized(op),
327 "{op} must be a valid floor target"
328 );
329 assert!(op_class::ALL.contains(&op), "{op} must be in ALL");
330 }
331 }
332
333 async fn ks() -> KeyspaceHandle {
334 let dir = tempfile::tempdir().expect("tempdir");
335 let dir = Box::leak(Box::new(dir));
337 let store = Store::open(&StoreConfig {
338 data_dir: dir.path().to_path_buf(),
339 })
340 .expect("open store");
341 store.keyspace("sessions").expect("keyspace")
342 }
343
344 fn sample(challenge: &str, expires_at: u64) -> PendingStepUp {
345 PendingStepUp {
346 challenge: challenge.to_string(),
347 session_id: "sess-1".to_string(),
348 subject: "did:key:zHolder".to_string(),
349 approver: "did:key:zHolder".to_string(),
350 approver_any: false,
351 target_acr: "aal2".to_string(),
352 acceptable_evidence: vec!["did-signed".into(), "webauthn".into()],
353 created_at: 1000,
354 expires_at,
355 }
356 }
357
358 #[tokio::test]
359 async fn round_trips_and_consumes_once() {
360 let ks = ks().await;
361 let p = sample("VHJhbnNmZXJDb25maXJtTm9uY2VYWQ", now_epoch() + 300);
362 store_pending_step_up(&ks, &p).await.unwrap();
363
364 assert_eq!(
366 get_pending_step_up(&ks, &p.challenge).await.unwrap(),
367 Some(p.clone())
368 );
369
370 match consume_pending_step_up(&ks, &p.challenge, now_epoch())
372 .await
373 .unwrap()
374 {
375 ConsumeOutcome::Found(found) => assert_eq!(*found, p),
376 other => panic!("expected Found, got {other:?}"),
377 }
378 assert_eq!(
380 consume_pending_step_up(&ks, &p.challenge, now_epoch())
381 .await
382 .unwrap(),
383 ConsumeOutcome::NotFound
384 );
385 }
386
387 #[tokio::test]
388 async fn unknown_challenge_is_not_found() {
389 let ks = ks().await;
390 assert_eq!(
391 consume_pending_step_up(&ks, "no-such-challenge", now_epoch())
392 .await
393 .unwrap(),
394 ConsumeOutcome::NotFound
395 );
396 }
397
398 #[tokio::test]
399 async fn expired_challenge_is_consumed_and_reported_expired() {
400 let ks = ks().await;
401 let p = sample("RXhwaXJlZENoYWxsZW5nZVZhbHVlWA", 1000); store_pending_step_up(&ks, &p).await.unwrap();
403 assert_eq!(
404 consume_pending_step_up(&ks, &p.challenge, now_epoch())
405 .await
406 .unwrap(),
407 ConsumeOutcome::Expired
408 );
409 assert_eq!(get_pending_step_up(&ks, &p.challenge).await.unwrap(), None);
411 }
412
413 #[test]
414 fn new_pending_sets_expiry() {
415 let p = new_pending_step_up(
416 "VHJhbnNmZXJDb25maXJtTm9uY2VYWQ",
417 "sess-1",
418 "did:key:zHolder",
419 "did:key:zApprover",
420 false,
421 "aal2",
422 vec!["webauthn".into()],
423 300,
424 );
425 assert_eq!(p.expires_at, p.created_at + 300);
426 assert_eq!(p.target_acr, "aal2");
427 assert_eq!(p.approver, "did:key:zApprover");
428 assert!(!p.approver_any);
429 }
430
431 #[test]
432 fn legacy_record_without_approver_defaults_empty() {
433 let legacy = r#"{
437 "challenge":"VHJhbnNmZXJDb25maXJtTm9uY2VYWQ",
438 "session_id":"sess-1",
439 "subject":"did:key:zHolder",
440 "target_acr":"aal2",
441 "acceptable_evidence":["did-signed"],
442 "created_at":1000,
443 "expires_at":2000
444 }"#;
445 let p: PendingStepUp = serde_json::from_str(legacy).expect("legacy record deserializes");
446 assert_eq!(p.approver, "");
447 assert_eq!(p.subject, "did:key:zHolder");
448 }
449
450 fn floor(op: &str, mode: StepUpMode) -> StepUpFloor {
451 StepUpFloor {
452 operation: op.to_string(),
453 mode,
454 allow_aal1_if_non_escalating: false,
455 }
456 }
457
458 #[test]
459 fn default_policy_is_disabled_and_never_gates() {
460 let p = StepUpPolicy::default();
461 assert!(!p.enabled);
462 assert_eq!(p.floor_for("acl/grant"), StepUpMode::None);
464 assert_eq!(p.floor_for("*"), StepUpMode::None);
465 assert!(!p.floor_for("anything").requires_aal2());
466 }
467
468 #[test]
469 fn disabled_policy_ignores_configured_floors() {
470 let p = StepUpPolicy {
471 enabled: false,
472 floors: vec![floor("*", StepUpMode::Delegated)],
473 };
474 assert_eq!(p.floor_for("acl/grant"), StepUpMode::None);
475 assert!(p.floor_record("acl/grant").is_none());
476 }
477
478 #[test]
479 fn enabled_resolves_exact_then_catch_all() {
480 let p = StepUpPolicy {
481 enabled: true,
482 floors: vec![
483 floor("*", StepUpMode::SelfApprove),
484 floor("acl/grant", StepUpMode::Delegated),
485 ],
486 };
487 assert_eq!(p.floor_for("acl/grant"), StepUpMode::Delegated);
489 assert_eq!(p.floor_for("context/delete"), StepUpMode::SelfApprove);
491 }
492
493 #[test]
494 fn enabled_without_catch_all_is_none_for_unlisted() {
495 let p = StepUpPolicy {
496 enabled: true,
497 floors: vec![floor("acl/grant", StepUpMode::Delegated)],
498 };
499 assert_eq!(p.floor_for("acl/swap-key"), StepUpMode::None);
500 assert_eq!(p.floor_for("acl/grant"), StepUpMode::Delegated);
501 }
502
503 #[test]
504 fn mode_strictness_is_additive() {
505 assert_eq!(
507 StepUpMode::SelfApprove.strictest(StepUpMode::Delegated),
508 StepUpMode::Delegated
509 );
510 assert_eq!(
511 StepUpMode::Delegated.strictest(StepUpMode::SelfApprove),
512 StepUpMode::Delegated
513 );
514 assert_eq!(
515 StepUpMode::None.strictest(StepUpMode::SelfApprove),
516 StepUpMode::SelfApprove
517 );
518 assert!(!StepUpMode::None.requires_aal2());
519 assert!(StepUpMode::SelfApprove.requires_aal2());
520 assert!(StepUpMode::DelegatedAny.requires_aal2());
521 }
522
523 #[test]
524 fn mode_serde_uses_spec_wire_tokens() {
525 assert_eq!(
526 serde_json::to_string(&StepUpMode::SelfApprove).unwrap(),
527 "\"self\""
528 );
529 assert_eq!(
530 serde_json::to_string(&StepUpMode::DelegatedAny).unwrap(),
531 "\"delegated-any\""
532 );
533 assert_eq!(
534 serde_json::from_str::<StepUpMode>("\"none\"").unwrap(),
535 StepUpMode::None
536 );
537 }
538}