Skip to main content

confium_coordinator/coordinator/
admin.rs

1//! Coordinator admin API — privileged operations for operators.
2
3use crate::coordinator::session::SessionState;
4use serde::{Deserialize, Serialize};
5
6/// Admin request types (require elevated privileges).
7#[derive(Debug, Clone, Serialize, Deserialize)]
8#[serde(tag = "action", rename_all = "snake_case")]
9pub enum AdminRequest {
10    /// Force-expire a specific session.
11    ForceExpireSession { session_id: String },
12    /// Drain: stop accepting new sessions, let active ones complete.
13    Drain,
14    /// List all sessions with their states.
15    ListSessions,
16    /// Purge all sessions for a quorum.
17    PurgeQuorum { quorum_id: String },
18    /// Get diagnostics report.
19    GetDiagnostics,
20}
21
22/// Admin response types.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(tag = "status", rename_all = "snake_case")]
25pub enum AdminResponse {
26    /// Operation succeeded.
27    Ok { message: String },
28    /// Session was expired.
29    SessionExpired { session_id: String },
30    /// Drain initiated.
31    Draining { active_sessions: usize },
32    /// Session list.
33    SessionList { sessions: Vec<SessionSummary> },
34    /// Quorum purged.
35    QuorumPurged { quorum_id: String, count: usize },
36    /// Diagnostics report.
37    Diagnostics { report_json: String },
38    /// Error.
39    Error { message: String },
40}
41
42/// Summary of a session for admin listing.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct SessionSummary {
45    pub session_id: String,
46    pub state: String,
47    pub threshold: u32,
48    pub commitments: usize,
49    pub shares: usize,
50}
51
52/// Errors during admin operations.
53#[derive(Debug, thiserror::Error)]
54pub enum AdminError {
55    #[error("session not found: {0}")]
56    SessionNotFound(String),
57    #[error("operation not permitted")]
58    NotPermitted,
59}
60
61/// Check if a requester has admin privileges. In production, this
62/// would check an admin token or mTLS client cert.
63pub fn is_admin(token: &str) -> bool {
64    !token.is_empty() && token.starts_with("admin-")
65}
66
67/// Execute an admin request against the coordinator.
68pub fn execute_admin(
69    request: &AdminRequest,
70    session_ids: &[String],
71    session_states: &dyn Fn(&str) -> Option<SessionState>,
72    session_threshold: &dyn Fn(&str) -> Option<u32>,
73    session_commitments: &dyn Fn(&str) -> Option<usize>,
74    session_shares: &dyn Fn(&str) -> Option<usize>,
75) -> AdminResponse {
76    match request {
77        AdminRequest::ForceExpireSession { session_id } => {
78            if !session_ids.contains(session_id) {
79                return AdminResponse::Error {
80                    message: format!("session not found: {session_id}"),
81                };
82            }
83            AdminResponse::SessionExpired {
84                session_id: session_id.clone(),
85            }
86        }
87        AdminRequest::Drain => {
88            let active = session_ids
89                .iter()
90                .filter(|sid| session_states(sid) == Some(SessionState::Pending))
91                .count();
92            AdminResponse::Draining {
93                active_sessions: active,
94            }
95        }
96        AdminRequest::ListSessions => {
97            let sessions: Vec<SessionSummary> = session_ids
98                .iter()
99                .filter_map(|sid| {
100                    let state = session_states(sid)?;
101                    let threshold = session_threshold(sid).unwrap_or(0);
102                    let commitments = session_commitments(sid).unwrap_or(0);
103                    let shares = session_shares(sid).unwrap_or(0);
104                    Some(SessionSummary {
105                        session_id: sid.clone(),
106                        state: format!("{state:?}"),
107                        threshold,
108                        commitments,
109                        shares,
110                    })
111                })
112                .collect();
113            AdminResponse::SessionList { sessions }
114        }
115        AdminRequest::PurgeQuorum { quorum_id: _ } => AdminResponse::QuorumPurged {
116            quorum_id: "placeholder".into(),
117            count: 0,
118        },
119        AdminRequest::GetDiagnostics => AdminResponse::Diagnostics {
120            report_json: "{}".into(),
121        },
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn admin_token_check() {
131        assert!(is_admin("admin-secret-123"));
132        assert!(!is_admin(""));
133        assert!(!is_admin("user-token"));
134    }
135
136    #[test]
137    fn force_expire_unknown_session_errors() {
138        let req = AdminRequest::ForceExpireSession {
139            session_id: "x".into(),
140        };
141        let resp = execute_admin(&req, &[], &|_| None, &|_| None, &|_| None, &|_| None);
142        match resp {
143            AdminResponse::Error { .. } => {}
144            _ => panic!("expected error"),
145        }
146    }
147
148    #[test]
149    fn force_expire_known_session() {
150        let req = AdminRequest::ForceExpireSession {
151            session_id: "s1".into(),
152        };
153        let resp = execute_admin(
154            &req,
155            &["s1".into()],
156            &|_| Some(SessionState::Pending),
157            &|_| Some(2),
158            &|_| Some(1),
159            &|_| Some(0),
160        );
161        match resp {
162            AdminResponse::SessionExpired { session_id } => assert_eq!(session_id, "s1"),
163            _ => panic!("expected SessionExpired"),
164        }
165    }
166
167    #[test]
168    fn drain_counts_active() {
169        let req = AdminRequest::Drain;
170        let ids = vec!["s1".into(), "s2".into(), "s3".into()];
171        let resp = execute_admin(
172            &req,
173            &ids,
174            &|sid| {
175                if sid == "s3" {
176                    Some(SessionState::Completed)
177                } else {
178                    Some(SessionState::Pending)
179                }
180            },
181            &|_| Some(2),
182            &|_| Some(0),
183            &|_| Some(0),
184        );
185        match resp {
186            AdminResponse::Draining { active_sessions } => assert_eq!(active_sessions, 2),
187            _ => panic!("expected Draining"),
188        }
189    }
190
191    #[test]
192    fn list_sessions_returns_summaries() {
193        let req = AdminRequest::ListSessions;
194        let ids = vec!["s1".into(), "s2".into()];
195        let resp = execute_admin(
196            &req,
197            &ids,
198            &|_| Some(SessionState::Pending),
199            &|_| Some(2),
200            &|sid| if sid == "s1" { Some(1) } else { Some(0) },
201            &|_| Some(0),
202        );
203        match resp {
204            AdminResponse::SessionList { sessions } => {
205                assert_eq!(sessions.len(), 2);
206                assert_eq!(sessions[0].commitments, 1);
207            }
208            _ => panic!("expected SessionList"),
209        }
210    }
211
212    #[test]
213    fn admin_request_serializes() {
214        let req = AdminRequest::ForceExpireSession {
215            session_id: "s1".into(),
216        };
217        let json = serde_json::to_string(&req).unwrap();
218        assert!(json.contains("force_expire_session"));
219    }
220
221    #[test]
222    fn admin_response_serializes() {
223        let resp = AdminResponse::Ok {
224            message: "done".into(),
225        };
226        let json = serde_json::to_string(&resp).unwrap();
227        assert!(json.contains("ok"));
228    }
229}