1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4#[derive(Debug, Clone, Deserialize, Serialize)]
5pub struct ServerConfig {
6 #[serde(default = "default_host")]
7 pub host: String,
8 pub port: u16,
11}
12
13#[derive(Debug, Clone, Deserialize, Serialize)]
14pub struct LogConfig {
15 #[serde(default = "default_log_level")]
16 pub level: String,
17 #[serde(default)]
18 pub format: LogFormat,
19}
20
21#[derive(Debug, Clone, Deserialize, Serialize)]
22pub struct StoreConfig {
23 pub data_dir: PathBuf,
26}
27
28#[derive(Clone, Deserialize, Serialize)]
29pub struct AuthConfig {
30 #[serde(default = "default_access_token_expiry")]
31 pub access_token_expiry: u64,
32 #[serde(default = "default_refresh_token_expiry")]
33 pub refresh_token_expiry: u64,
34 #[serde(default = "default_challenge_ttl")]
35 pub challenge_ttl: u64,
36 #[serde(default = "default_session_cleanup_interval")]
37 pub session_cleanup_interval: u64,
38 pub jwt_signing_key: Option<String>,
40 #[serde(default, deserialize_with = "refuse_retired_step_up", skip_serializing)]
52 pub step_up: (),
53}
54
55fn refuse_retired_step_up<'de, D>(_: D) -> Result<(), D::Error>
60where
61 D: serde::Deserializer<'de>,
62{
63 Err(serde::de::Error::custom(
64 "`[auth.step_up]` has been retired. The step-up floors were a second, \
65 parallel answer to \"does this operation need another human decision?\", \
66 resolved separately from the policy rules — which is how a VTA could \
67 demand a step-up that no rule explained. Approvals are now one model: \
68 delete the `[auth.step_up]` section and express the same requirement as \
69 a rule with `pnm approvals require <task-uri> --reauth` (or \
70 `--consent`). `pnm approvals list` then shows every gated operation, \
71 which the floors never could.",
72 ))
73}
74
75impl std::fmt::Debug for AuthConfig {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 f.debug_struct("AuthConfig")
84 .field("access_token_expiry", &self.access_token_expiry)
85 .field("refresh_token_expiry", &self.refresh_token_expiry)
86 .field("challenge_ttl", &self.challenge_ttl)
87 .field("session_cleanup_interval", &self.session_cleanup_interval)
88 .field(
89 "jwt_signing_key",
90 &self.jwt_signing_key.as_ref().map(|_| "<redacted>"),
91 )
92 .finish()
93 }
94}
95
96#[derive(Debug, Clone, Deserialize, Serialize)]
97pub struct MessagingConfig {
98 #[serde(default)]
101 pub mediator_url: String,
102 pub mediator_did: String,
103 #[serde(default)]
107 pub mediator_host: Option<String>,
108 #[serde(default)]
113 pub setup_acl: bool,
114 #[serde(default)]
129 pub drain_inbox_on_start: bool,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct AuditConfig {
134 #[serde(default = "default_audit_retention_days")]
136 pub retention_days: u32,
137}
138
139fn default_audit_retention_days() -> u32 {
140 28
141}
142
143impl Default for AuditConfig {
144 fn default() -> Self {
145 Self {
146 retention_days: default_audit_retention_days(),
147 }
148 }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct VaultConfig {
155 #[serde(default = "default_vault_grace_days")]
161 pub grace_days: u32,
162
163 #[serde(default)]
178 pub mdoc_iaca_trust_anchors: Vec<String>,
179}
180
181fn default_vault_grace_days() -> u32 {
182 30
183}
184
185impl Default for VaultConfig {
186 fn default() -> Self {
187 Self {
188 grace_days: default_vault_grace_days(),
189 mdoc_iaca_trust_anchors: Vec::new(),
190 }
191 }
192}
193
194#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
195#[serde(rename_all = "lowercase")]
196pub enum LogFormat {
197 #[default]
198 Text,
199 Json,
200}
201
202fn default_host() -> String {
203 "0.0.0.0".to_string()
204}
205
206fn default_log_level() -> String {
207 "info".to_string()
208}
209
210fn default_access_token_expiry() -> u64 {
211 900
212}
213
214fn default_refresh_token_expiry() -> u64 {
215 86400
216}
217
218fn default_challenge_ttl() -> u64 {
219 300
220}
221
222fn default_session_cleanup_interval() -> u64 {
223 600
224}
225
226impl Default for AuthConfig {
227 fn default() -> Self {
228 Self {
229 access_token_expiry: default_access_token_expiry(),
230 refresh_token_expiry: default_refresh_token_expiry(),
231 challenge_ttl: default_challenge_ttl(),
232 session_cleanup_interval: default_session_cleanup_interval(),
233 jwt_signing_key: None,
234 step_up: (),
235 }
236 }
237}
238
239impl Default for LogConfig {
240 fn default() -> Self {
241 Self {
242 level: default_log_level(),
243 format: LogFormat::default(),
244 }
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
257 fn auth_config_debug_redacts_jwt_signing_key() {
258 let cfg = AuthConfig {
259 access_token_expiry: 900,
260 refresh_token_expiry: 86400,
261 challenge_ttl: 300,
262 session_cleanup_interval: 600,
263 jwt_signing_key: Some("SUPER_SECRET_KEY_MATERIAL_MUST_NOT_LEAK".into()),
264 step_up: (),
265 };
266 let dbg = format!("{cfg:?}");
267 assert!(
268 !dbg.contains("SUPER_SECRET_KEY_MATERIAL"),
269 "AuthConfig Debug leaked jwt_signing_key contents: {dbg}"
270 );
271 assert!(
272 dbg.contains("<redacted>"),
273 "expected redaction marker in Debug, got: {dbg}"
274 );
275 assert!(
277 dbg.contains("900"),
278 "access_token_expiry must still be visible: {dbg}"
279 );
280 }
281
282 #[test]
283 fn auth_config_debug_none_signing_key_renders_none() {
284 let cfg = AuthConfig::default();
285 let dbg = format!("{cfg:?}");
286 assert!(dbg.contains("jwt_signing_key: None"), "got: {dbg}");
288 }
289
290 #[test]
296 fn auth_config_serialize_still_carries_jwt_signing_key() {
297 let cfg = AuthConfig {
298 access_token_expiry: 900,
299 refresh_token_expiry: 86400,
300 challenge_ttl: 300,
301 session_cleanup_interval: 600,
302 jwt_signing_key: Some("key-material".into()),
303 step_up: (),
304 };
305 let json = serde_json::to_string(&cfg).expect("serialize");
306 assert!(
307 json.contains("key-material"),
308 "Serialize must not redact — config persistence relies on round-trip: {json}"
309 );
310 }
311
312 #[test]
319 fn a_config_still_carrying_the_retired_floors_is_refused() {
320 let with_floors = r#"{
321 "jwt_signing_key": null,
322 "step_up": { "enabled": true, "floors": [{ "operation": "*", "mode": "self" }] }
323 }"#;
324 let err = serde_json::from_str::<AuthConfig>(with_floors)
325 .expect_err("`[auth.step_up]` must be refused, not ignored");
326 let msg = err.to_string();
327 assert!(msg.contains("retired"), "got: {msg}");
328 assert!(
329 msg.contains("pnm approvals require"),
330 "the refusal must name what replaces it, got: {msg}"
331 );
332
333 assert!(
336 serde_json::from_str::<AuthConfig>(r#"{"jwt_signing_key":null,"step_up":{}}"#).is_err()
337 );
338 }
339
340 #[test]
342 fn a_config_without_the_retired_section_loads() {
343 let cfg: AuthConfig =
344 serde_json::from_str(r#"{ "jwt_signing_key": null }"#).expect("loads");
345 assert_eq!(cfg.access_token_expiry, default_access_token_expiry());
346 }
347}
348
349#[cfg(test)]
350mod mdoc_trust_anchor_config_tests {
351 use super::*;
352
353 #[test]
357 fn trust_anchors_default_to_empty_and_an_old_config_still_loads() {
358 let cfg: VaultConfig = toml::from_str("grace_days = 30").expect("legacy config loads");
359 assert_eq!(cfg.grace_days, 30);
360 assert!(
361 cfg.mdoc_iaca_trust_anchors.is_empty(),
362 "absent means no mdoc issuer is trusted, not a permissive default"
363 );
364 }
365
366 #[test]
367 fn trust_anchors_round_trip_through_toml() {
368 let cfg: VaultConfig = toml::from_str(
369 r#"
370 grace_days = 7
371 mdoc_iaca_trust_anchors = ["-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n"]
372 "#,
373 )
374 .expect("config with anchors loads");
375 assert_eq!(cfg.mdoc_iaca_trust_anchors.len(), 1);
376 assert!(cfg.mdoc_iaca_trust_anchors[0].contains("BEGIN CERTIFICATE"));
377 }
378}