1use alloc::{
32 collections::BTreeMap,
33 string::{String, ToString},
34 vec::Vec,
35};
36use core::net::IpAddr;
37
38use chrono::{DateTime, Utc};
39
40const WILDCARD_USER: &str = "*";
42const IDENTITY_MAP: &str = "=";
44
45#[derive(Default, Debug, Clone, PartialEq, Eq)]
47pub struct SshPolicy {
48 pub rules: Vec<SshRule>,
50}
51
52#[derive(Default, Debug, Clone, PartialEq, Eq)]
54pub struct SshRule {
55 pub rule_expires: Option<DateTime<Utc>>,
57 pub principals: Vec<SshPrincipal>,
59 pub ssh_users: BTreeMap<String, String>,
61 pub action: Option<SshAction>,
63 pub accept_env: Vec<String>,
65}
66
67#[derive(Default, Debug, Clone, PartialEq, Eq)]
70pub struct SshPrincipal {
71 pub node: String,
73 pub node_ip: String,
75 pub user_login: String,
77 pub any: bool,
79}
80
81#[derive(Default, Debug, Clone, PartialEq, Eq)]
85pub struct SshAction {
86 pub message: String,
88 pub reject: bool,
90 pub accept: bool,
92 pub session_duration_nanos: Option<i64>,
94 pub allow_agent_forwarding: bool,
96 pub allow_local_port_forwarding: bool,
98 pub allow_remote_port_forwarding: bool,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct SshConnIdentity {
105 pub stable_id: String,
107 pub src_ip: IpAddr,
109 pub user_login: Option<String>,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum SshDecision {
117 Accept(SshAccept),
119 Deny(SshDenyReason),
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct SshAccept {
126 pub local_user: String,
128 pub accept_env: Vec<String>,
130 pub session_duration_nanos: Option<i64>,
132 pub allow_agent_forwarding: bool,
134 pub allow_local_port_forwarding: bool,
136 pub allow_remote_port_forwarding: bool,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum SshDenyReason {
144 ExplicitReject {
146 message: String,
148 },
149 NoRuleMatched,
151 NoUserMapping,
153}
154
155enum RuleSkip {
158 NoMatch,
160 UserMatch,
162}
163
164impl SshPolicy {
165 pub fn from_serde(p: &ts_control_serde::SSHPolicy<'_>) -> Self {
167 SshPolicy {
168 rules: p.rules.iter().map(SshRule::from_serde).collect(),
169 }
170 }
171
172 pub fn evaluate_at_unix(
180 &self,
181 id: &SshConnIdentity,
182 requested_user: &str,
183 now_unix_secs: i64,
184 ) -> SshDecision {
185 let now = DateTime::from_timestamp(now_unix_secs, 0).unwrap_or(DateTime::<Utc>::MAX_UTC);
190 self.evaluate(id, requested_user, now)
191 }
192
193 pub fn evaluate(
199 &self,
200 id: &SshConnIdentity,
201 requested_user: &str,
202 now: DateTime<Utc>,
203 ) -> SshDecision {
204 let mut failed_on_user = false;
205
206 for rule in &self.rules {
207 match rule.try_match(id, requested_user, now) {
208 Ok(decision) => return decision,
209 Err(RuleSkip::UserMatch) => failed_on_user = true,
210 Err(RuleSkip::NoMatch) => {}
211 }
212 }
213
214 SshDecision::Deny(if failed_on_user {
215 SshDenyReason::NoUserMapping
216 } else {
217 SshDenyReason::NoRuleMatched
218 })
219 }
220}
221
222impl SshRule {
223 fn from_serde(r: &ts_control_serde::SSHRule<'_>) -> Self {
224 SshRule {
225 rule_expires: r.rule_expires,
226 principals: r.principals.iter().map(SshPrincipal::from_serde).collect(),
227 ssh_users: r
228 .ssh_users
229 .iter()
230 .map(|(k, v)| (k.to_string(), v.to_string()))
231 .collect(),
232 action: r.action.as_ref().map(SshAction::from_serde),
233 accept_env: r.accept_env.iter().map(|s| s.to_string()).collect(),
234 }
235 }
236
237 fn try_match(
239 &self,
240 id: &SshConnIdentity,
241 requested_user: &str,
242 now: DateTime<Utc>,
243 ) -> Result<SshDecision, RuleSkip> {
244 let action = self.action.as_ref().ok_or(RuleSkip::NoMatch)?;
246
247 if self.is_expired(now) {
249 return Err(RuleSkip::NoMatch);
250 }
251
252 if !self.principals.iter().any(|p| p.matches(id)) {
254 return Err(RuleSkip::NoMatch);
255 }
256
257 if action.reject {
260 return Ok(SshDecision::Deny(SshDenyReason::ExplicitReject {
261 message: action.message.clone(),
262 }));
263 }
264
265 let local_user =
267 map_local_user(&self.ssh_users, requested_user).ok_or(RuleSkip::UserMatch)?;
268
269 Ok(SshDecision::Accept(SshAccept {
270 local_user,
271 accept_env: self.accept_env.clone(),
272 session_duration_nanos: action.session_duration_nanos,
273 allow_agent_forwarding: action.allow_agent_forwarding,
274 allow_local_port_forwarding: action.allow_local_port_forwarding,
275 allow_remote_port_forwarding: action.allow_remote_port_forwarding,
276 }))
277 }
278
279 fn is_expired(&self, now: DateTime<Utc>) -> bool {
280 match self.rule_expires {
281 None => false,
282 Some(expiry) => expiry < now,
283 }
284 }
285}
286
287impl SshPrincipal {
288 fn from_serde(p: &ts_control_serde::SSHPrincipal<'_>) -> Self {
289 SshPrincipal {
290 node: p.node.0.to_string(),
291 node_ip: p.node_ip.to_string(),
292 user_login: p.user_login.to_string(),
293 any: p.any,
294 }
295 }
296
297 fn matches(&self, id: &SshConnIdentity) -> bool {
301 if self.any {
302 return true;
303 }
304 if !self.node.is_empty() && self.node == id.stable_id {
305 return true;
306 }
307 if !self.node_ip.is_empty()
308 && self
309 .node_ip
310 .parse::<IpAddr>()
311 .is_ok_and(|ip| ip == id.src_ip)
312 {
313 return true;
314 }
315 if !self.user_login.is_empty()
316 && id
317 .user_login
318 .as_deref()
319 .is_some_and(|login| login == self.user_login)
320 {
321 return true;
322 }
323 false
324 }
325}
326
327impl SshAction {
328 fn from_serde(a: &ts_control_serde::SSHAction<'_>) -> Self {
329 SshAction {
330 message: a.message.to_string(),
331 reject: a.reject,
332 accept: a.accept,
333 session_duration_nanos: a.session_duration.filter(|d| *d != 0),
335 allow_agent_forwarding: a.allow_agent_forwarding,
336 allow_local_port_forwarding: a.allow_local_port_forwarding,
337 allow_remote_port_forwarding: a.allow_remote_port_forwarding,
338 }
339 }
340}
341
342fn map_local_user(ssh_users: &BTreeMap<String, String>, requested_user: &str) -> Option<String> {
346 let mapped = ssh_users
347 .get(requested_user)
348 .or_else(|| ssh_users.get(WILDCARD_USER))?;
349
350 if mapped.is_empty() {
351 return None;
352 }
353 if mapped == IDENTITY_MAP {
354 return Some(requested_user.to_string());
355 }
356 Some(mapped.clone())
357}
358
359#[cfg(test)]
360mod tests {
361 use alloc::vec;
362
363 use super::*;
364
365 fn ip(s: &str) -> IpAddr {
366 s.parse().unwrap()
367 }
368
369 fn now() -> DateTime<Utc> {
371 "2026-06-05T00:00:00Z".parse().unwrap()
372 }
373
374 fn id(stable_id: &str, src: &str, login: Option<&str>) -> SshConnIdentity {
375 SshConnIdentity {
376 stable_id: stable_id.to_string(),
377 src_ip: ip(src),
378 user_login: login.map(|s| s.to_string()),
379 }
380 }
381
382 fn accept_rule(principals: Vec<SshPrincipal>, ssh_users: &[(&str, &str)]) -> SshRule {
383 SshRule {
384 rule_expires: None,
385 principals,
386 ssh_users: ssh_users
387 .iter()
388 .map(|(k, v)| (k.to_string(), v.to_string()))
389 .collect(),
390 action: Some(SshAction {
391 accept: true,
392 ..Default::default()
393 }),
394 accept_env: vec![],
395 }
396 }
397
398 fn any_principal() -> SshPrincipal {
399 SshPrincipal {
400 any: true,
401 ..Default::default()
402 }
403 }
404
405 #[test]
406 fn empty_policy_denies() {
407 let pol = SshPolicy::default();
408 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
409 assert_eq!(d, SshDecision::Deny(SshDenyReason::NoRuleMatched));
410 }
411
412 #[test]
413 fn any_principal_with_wildcard_user_accepts_identity_map() {
414 let pol = SshPolicy {
415 rules: vec![accept_rule(vec![any_principal()], &[("*", "=")])],
416 };
417 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "ubuntu", now());
418 match d {
419 SshDecision::Accept(a) => assert_eq!(a.local_user, "ubuntu"),
420 other => panic!("expected accept, got {other:?}"),
421 }
422 }
423
424 #[test]
425 fn wildcard_user_with_fixed_local_user() {
426 let pol = SshPolicy {
427 rules: vec![accept_rule(vec![any_principal()], &[("*", "deploy")])],
428 };
429 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "anything", now());
430 match d {
431 SshDecision::Accept(a) => assert_eq!(a.local_user, "deploy"),
432 other => panic!("expected accept, got {other:?}"),
433 }
434 }
435
436 #[test]
437 fn empty_string_user_value_denies_as_no_user_mapping() {
438 let pol = SshPolicy {
441 rules: vec![accept_rule(vec![any_principal()], &[("root", "")])],
442 };
443 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
444 assert_eq!(d, SshDecision::Deny(SshDenyReason::NoUserMapping));
445 }
446
447 #[test]
448 fn no_matching_user_key_falls_through_to_no_user_mapping() {
449 let pol = SshPolicy {
451 rules: vec![accept_rule(vec![any_principal()], &[("alice", "alice")])],
452 };
453 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
454 assert_eq!(d, SshDecision::Deny(SshDenyReason::NoUserMapping));
455 }
456
457 #[test]
458 fn specific_user_key_preferred_over_wildcard() {
459 let pol = SshPolicy {
460 rules: vec![accept_rule(
461 vec![any_principal()],
462 &[("root", "rootlocal"), ("*", "nobody")],
463 )],
464 };
465 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
466 match d {
467 SshDecision::Accept(a) => assert_eq!(a.local_user, "rootlocal"),
468 other => panic!("expected accept, got {other:?}"),
469 }
470 }
471
472 #[test]
473 fn principal_matches_by_stable_id() {
474 let pol = SshPolicy {
475 rules: vec![accept_rule(
476 vec![SshPrincipal {
477 node: "nABC".to_string(),
478 ..Default::default()
479 }],
480 &[("*", "=")],
481 )],
482 };
483 let yes = pol.evaluate(&id("nABC", "100.64.0.9", None), "u", now());
484 assert!(matches!(yes, SshDecision::Accept(_)));
485 let no = pol.evaluate(&id("nXYZ", "100.64.0.9", None), "u", now());
486 assert_eq!(no, SshDecision::Deny(SshDenyReason::NoRuleMatched));
487 }
488
489 #[test]
490 fn principal_matches_by_node_ip() {
491 let pol = SshPolicy {
492 rules: vec![accept_rule(
493 vec![SshPrincipal {
494 node_ip: "100.64.0.7".to_string(),
495 ..Default::default()
496 }],
497 &[("*", "=")],
498 )],
499 };
500 let yes = pol.evaluate(&id("n1", "100.64.0.7", None), "u", now());
501 assert!(matches!(yes, SshDecision::Accept(_)));
502 let no = pol.evaluate(&id("n1", "100.64.0.8", None), "u", now());
503 assert_eq!(no, SshDecision::Deny(SshDenyReason::NoRuleMatched));
504 }
505
506 #[test]
507 fn principal_matches_by_user_login() {
508 let pol = SshPolicy {
509 rules: vec![accept_rule(
510 vec![SshPrincipal {
511 user_login: "alice@example.com".to_string(),
512 ..Default::default()
513 }],
514 &[("*", "=")],
515 )],
516 };
517 let yes = pol.evaluate(
518 &id("n1", "100.64.0.1", Some("alice@example.com")),
519 "u",
520 now(),
521 );
522 assert!(matches!(yes, SshDecision::Accept(_)));
523 let no = pol.evaluate(&id("n1", "100.64.0.1", None), "u", now());
525 assert_eq!(no, SshDecision::Deny(SshDenyReason::NoRuleMatched));
526 }
527
528 #[test]
529 fn all_empty_non_any_principal_matches_nothing() {
530 let pol = SshPolicy {
531 rules: vec![accept_rule(vec![SshPrincipal::default()], &[("*", "=")])],
532 };
533 let d = pol.evaluate(&id("n1", "100.64.0.1", Some("a@b")), "u", now());
534 assert_eq!(d, SshDecision::Deny(SshDenyReason::NoRuleMatched));
535 }
536
537 #[test]
538 fn explicit_reject_short_circuits_before_user_mapping() {
539 let pol = SshPolicy {
541 rules: vec![SshRule {
542 principals: vec![any_principal()],
543 action: Some(SshAction {
544 reject: true,
545 message: "go away".to_string(),
546 ..Default::default()
547 }),
548 ..Default::default()
549 }],
550 };
551 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
552 assert_eq!(
553 d,
554 SshDecision::Deny(SshDenyReason::ExplicitReject {
555 message: "go away".to_string()
556 })
557 );
558 }
559
560 #[test]
561 fn first_matching_rule_wins() {
562 let pol = SshPolicy {
564 rules: vec![
565 SshRule {
566 principals: vec![any_principal()],
567 action: Some(SshAction {
568 reject: true,
569 ..Default::default()
570 }),
571 ..Default::default()
572 },
573 accept_rule(vec![any_principal()], &[("*", "=")]),
574 ],
575 };
576 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
577 assert!(matches!(
578 d,
579 SshDecision::Deny(SshDenyReason::ExplicitReject { .. })
580 ));
581 }
582
583 #[test]
584 fn rule_with_no_action_is_skipped() {
585 let pol = SshPolicy {
586 rules: vec![
587 SshRule {
588 principals: vec![any_principal()],
589 action: None,
590 ..Default::default()
591 },
592 accept_rule(vec![any_principal()], &[("*", "=")]),
593 ],
594 };
595 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
596 assert!(matches!(d, SshDecision::Accept(_)));
597 }
598
599 #[test]
600 fn expired_rule_is_skipped() {
601 let past = "2000-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
602 let pol = SshPolicy {
603 rules: vec![SshRule {
604 rule_expires: Some(past),
605 ..accept_rule(vec![any_principal()], &[("*", "=")])
606 }],
607 };
608 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
609 assert_eq!(d, SshDecision::Deny(SshDenyReason::NoRuleMatched));
610 }
611
612 #[test]
613 fn unexpired_rule_still_matches() {
614 let future = "2999-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
615 let pol = SshPolicy {
616 rules: vec![SshRule {
617 rule_expires: Some(future),
618 ..accept_rule(vec![any_principal()], &[("*", "=")])
619 }],
620 };
621 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
622 assert!(matches!(d, SshDecision::Accept(_)));
623 }
624
625 #[test]
626 fn evaluate_at_unix_far_future_expires_time_limited_rules() {
627 let future = "2999-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
630 let pol = SshPolicy {
631 rules: vec![SshRule {
632 rule_expires: Some(future),
633 ..accept_rule(vec![any_principal()], &[("*", "=")])
634 }],
635 };
636 let d = pol.evaluate_at_unix(&id("n1", "100.64.0.1", None), "root", i64::MAX);
637 assert_eq!(d, SshDecision::Deny(SshDenyReason::NoRuleMatched));
638 }
639
640 #[test]
641 fn session_duration_zero_is_unlimited() {
642 let serde_action = ts_control_serde::SSHAction {
643 accept: true,
644 session_duration: Some(0),
645 ..Default::default()
646 };
647 assert_eq!(
648 SshAction::from_serde(&serde_action).session_duration_nanos,
649 None
650 );
651 }
652
653 #[test]
654 fn from_serde_round_trips_a_policy() {
655 let wire = r#"{
656 "rules": [
657 {
658 "principals": [{ "any": true }],
659 "sshUsers": { "*": "=" },
660 "action": { "accept": true, "allowAgentForwarding": true }
661 }
662 ]
663 }"#;
664 let serde_pol: ts_control_serde::SSHPolicy = serde_json::from_str(wire).unwrap();
665 let pol = SshPolicy::from_serde(&serde_pol);
666
667 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "ubuntu", now());
668 match d {
669 SshDecision::Accept(a) => {
670 assert_eq!(a.local_user, "ubuntu");
671 assert!(a.allow_agent_forwarding);
672 }
673 other => panic!("expected accept, got {other:?}"),
674 }
675 }
676}