1use alloc::{
32 collections::BTreeMap,
33 string::{String, ToString},
34 vec::Vec,
35};
36use core::net::{IpAddr, SocketAddr};
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)]
89pub struct SshAction {
90 pub message: String,
92 pub reject: bool,
94 pub accept: bool,
96 pub session_duration_nanos: Option<i64>,
98 pub allow_agent_forwarding: bool,
100 pub allow_local_port_forwarding: bool,
102 pub allow_remote_port_forwarding: bool,
104 pub recorders: Vec<SocketAddr>,
107 pub on_recording_failure: Option<SshRecorderFailureAction>,
111 pub hold_and_delegate: String,
119}
120
121#[derive(Default, Debug, Clone, PartialEq, Eq)]
124pub struct SshRecorderFailureAction {
125 pub reject_session_with_message: String,
128 pub terminate_session_with_message: String,
130 pub notify_url: String,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct SshConnIdentity {
137 pub stable_id: String,
139 pub src_ip: IpAddr,
141 pub user_login: Option<String>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum SshDecision {
149 Accept(SshAccept),
151 Deny(SshDenyReason),
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct SshAccept {
158 pub local_user: String,
160 pub accept_env: Vec<String>,
162 pub session_duration_nanos: Option<i64>,
164 pub allow_agent_forwarding: bool,
166 pub allow_local_port_forwarding: bool,
168 pub allow_remote_port_forwarding: bool,
170 pub recorders: Vec<SocketAddr>,
173 pub recording_required: bool,
181 pub recording_refusal_message: String,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum SshDenyReason {
191 ExplicitReject {
193 message: String,
195 },
196 NoRuleMatched,
198 NoUserMapping,
200}
201
202enum RuleSkip {
205 NoMatch,
207 UserMatch,
209}
210
211impl SshPolicy {
212 pub fn from_serde(p: &ts_control_serde::SSHPolicy<'_>) -> Self {
214 SshPolicy {
215 rules: p.rules.iter().map(SshRule::from_serde).collect(),
216 }
217 }
218
219 pub fn evaluate_at_unix(
227 &self,
228 id: &SshConnIdentity,
229 requested_user: &str,
230 now_unix_secs: i64,
231 ) -> SshDecision {
232 let now = DateTime::from_timestamp(now_unix_secs, 0).unwrap_or(DateTime::<Utc>::MAX_UTC);
237 self.evaluate(id, requested_user, now)
238 }
239
240 pub fn evaluate(
246 &self,
247 id: &SshConnIdentity,
248 requested_user: &str,
249 now: DateTime<Utc>,
250 ) -> SshDecision {
251 let mut failed_on_user = false;
252
253 for rule in &self.rules {
254 match rule.try_match(id, requested_user, now) {
255 Ok(decision) => return decision,
256 Err(RuleSkip::UserMatch) => failed_on_user = true,
257 Err(RuleSkip::NoMatch) => {}
258 }
259 }
260
261 SshDecision::Deny(if failed_on_user {
262 SshDenyReason::NoUserMapping
263 } else {
264 SshDenyReason::NoRuleMatched
265 })
266 }
267}
268
269impl SshRule {
270 fn from_serde(r: &ts_control_serde::SSHRule<'_>) -> Self {
271 SshRule {
272 rule_expires: r.rule_expires,
273 principals: r.principals.iter().map(SshPrincipal::from_serde).collect(),
274 ssh_users: r
275 .ssh_users
276 .iter()
277 .map(|(k, v)| (k.to_string(), v.to_string()))
278 .collect(),
279 action: r.action.as_ref().map(SshAction::from_serde),
280 accept_env: r.accept_env.iter().map(|s| s.to_string()).collect(),
281 }
282 }
283
284 fn try_match(
286 &self,
287 id: &SshConnIdentity,
288 requested_user: &str,
289 now: DateTime<Utc>,
290 ) -> Result<SshDecision, RuleSkip> {
291 let action = self.action.as_ref().ok_or(RuleSkip::NoMatch)?;
293
294 if self.is_expired(now) {
296 return Err(RuleSkip::NoMatch);
297 }
298
299 if !self.principals.iter().any(|p| p.matches(id)) {
301 return Err(RuleSkip::NoMatch);
302 }
303
304 if action.reject {
307 return Ok(SshDecision::Deny(SshDenyReason::ExplicitReject {
308 message: action.message.clone(),
309 }));
310 }
311
312 let local_user =
314 map_local_user(&self.ssh_users, requested_user).ok_or(RuleSkip::UserMatch)?;
315
316 let recording_required =
323 !action.recorders.is_empty() || !action.hold_and_delegate.is_empty();
324 let recording_refusal_message = action.recording_refusal_message();
325
326 Ok(SshDecision::Accept(SshAccept {
327 local_user,
328 accept_env: self.accept_env.clone(),
329 session_duration_nanos: action.session_duration_nanos,
330 allow_agent_forwarding: action.allow_agent_forwarding,
331 allow_local_port_forwarding: action.allow_local_port_forwarding,
332 allow_remote_port_forwarding: action.allow_remote_port_forwarding,
333 recorders: action.recorders.clone(),
334 recording_required,
335 recording_refusal_message,
336 }))
337 }
338
339 fn is_expired(&self, now: DateTime<Utc>) -> bool {
340 match self.rule_expires {
341 None => false,
342 Some(expiry) => expiry < now,
343 }
344 }
345}
346
347impl SshPrincipal {
348 fn from_serde(p: &ts_control_serde::SSHPrincipal<'_>) -> Self {
349 SshPrincipal {
350 node: p.node.0.to_string(),
351 node_ip: p.node_ip.to_string(),
352 user_login: p.user_login.to_string(),
353 any: p.any,
354 }
355 }
356
357 fn matches(&self, id: &SshConnIdentity) -> bool {
361 if self.any {
362 return true;
363 }
364 if !self.node.is_empty() && self.node == id.stable_id {
365 return true;
366 }
367 if !self.node_ip.is_empty()
368 && self
369 .node_ip
370 .parse::<IpAddr>()
371 .is_ok_and(|ip| ip == id.src_ip)
372 {
373 return true;
374 }
375 if !self.user_login.is_empty()
376 && id
377 .user_login
378 .as_deref()
379 .is_some_and(|login| login == self.user_login)
380 {
381 return true;
382 }
383 false
384 }
385}
386
387impl SshAction {
388 fn from_serde(a: &ts_control_serde::SSHAction<'_>) -> Self {
389 SshAction {
390 message: a.message.to_string(),
391 reject: a.reject,
392 accept: a.accept,
393 session_duration_nanos: a.session_duration.filter(|d| *d != 0),
395 allow_agent_forwarding: a.allow_agent_forwarding,
396 allow_local_port_forwarding: a.allow_local_port_forwarding,
397 allow_remote_port_forwarding: a.allow_remote_port_forwarding,
398 recorders: a.recorders.clone(),
402 on_recording_failure: a
403 .on_recording_failure
404 .as_ref()
405 .map(SshRecorderFailureAction::from_serde),
406 hold_and_delegate: a.hold_and_delegate.to_string(),
407 }
408 }
409
410 fn recording_refusal_message(&self) -> String {
416 if let Some(orf) = &self.on_recording_failure
417 && !orf.reject_session_with_message.is_empty()
418 {
419 return orf.reject_session_with_message.clone();
420 }
421 self.message.clone()
422 }
423}
424
425impl SshRecorderFailureAction {
426 fn from_serde(f: &ts_control_serde::SSHRecorderFailureAction<'_>) -> Self {
427 SshRecorderFailureAction {
428 reject_session_with_message: f.reject_session_with_message.to_string(),
429 terminate_session_with_message: f.terminate_session_with_message.to_string(),
430 notify_url: f.notify_url.to_string(),
431 }
432 }
433}
434
435fn map_local_user(ssh_users: &BTreeMap<String, String>, requested_user: &str) -> Option<String> {
439 let mapped = ssh_users
440 .get(requested_user)
441 .or_else(|| ssh_users.get(WILDCARD_USER))?;
442
443 if mapped.is_empty() {
444 return None;
445 }
446 if mapped == IDENTITY_MAP {
447 return Some(requested_user.to_string());
448 }
449 Some(mapped.clone())
450}
451
452#[cfg(test)]
453mod tests {
454 use alloc::vec;
455
456 use super::*;
457
458 fn ip(s: &str) -> IpAddr {
459 s.parse().unwrap()
460 }
461
462 fn now() -> DateTime<Utc> {
464 "2026-06-05T00:00:00Z".parse().unwrap()
465 }
466
467 fn id(stable_id: &str, src: &str, login: Option<&str>) -> SshConnIdentity {
468 SshConnIdentity {
469 stable_id: stable_id.to_string(),
470 src_ip: ip(src),
471 user_login: login.map(|s| s.to_string()),
472 }
473 }
474
475 fn accept_rule(principals: Vec<SshPrincipal>, ssh_users: &[(&str, &str)]) -> SshRule {
476 SshRule {
477 rule_expires: None,
478 principals,
479 ssh_users: ssh_users
480 .iter()
481 .map(|(k, v)| (k.to_string(), v.to_string()))
482 .collect(),
483 action: Some(SshAction {
484 accept: true,
485 ..Default::default()
486 }),
487 accept_env: vec![],
488 }
489 }
490
491 fn any_principal() -> SshPrincipal {
492 SshPrincipal {
493 any: true,
494 ..Default::default()
495 }
496 }
497
498 #[test]
499 fn empty_policy_denies() {
500 let pol = SshPolicy::default();
501 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
502 assert_eq!(d, SshDecision::Deny(SshDenyReason::NoRuleMatched));
503 }
504
505 #[test]
506 fn any_principal_with_wildcard_user_accepts_identity_map() {
507 let pol = SshPolicy {
508 rules: vec![accept_rule(vec![any_principal()], &[("*", "=")])],
509 };
510 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "ubuntu", now());
511 match d {
512 SshDecision::Accept(a) => assert_eq!(a.local_user, "ubuntu"),
513 other => panic!("expected accept, got {other:?}"),
514 }
515 }
516
517 #[test]
518 fn wildcard_user_with_fixed_local_user() {
519 let pol = SshPolicy {
520 rules: vec![accept_rule(vec![any_principal()], &[("*", "deploy")])],
521 };
522 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "anything", now());
523 match d {
524 SshDecision::Accept(a) => assert_eq!(a.local_user, "deploy"),
525 other => panic!("expected accept, got {other:?}"),
526 }
527 }
528
529 #[test]
530 fn empty_string_user_value_denies_as_no_user_mapping() {
531 let pol = SshPolicy {
534 rules: vec![accept_rule(vec![any_principal()], &[("root", "")])],
535 };
536 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
537 assert_eq!(d, SshDecision::Deny(SshDenyReason::NoUserMapping));
538 }
539
540 #[test]
541 fn no_matching_user_key_falls_through_to_no_user_mapping() {
542 let pol = SshPolicy {
544 rules: vec![accept_rule(vec![any_principal()], &[("alice", "alice")])],
545 };
546 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
547 assert_eq!(d, SshDecision::Deny(SshDenyReason::NoUserMapping));
548 }
549
550 #[test]
551 fn specific_user_key_preferred_over_wildcard() {
552 let pol = SshPolicy {
553 rules: vec![accept_rule(
554 vec![any_principal()],
555 &[("root", "rootlocal"), ("*", "nobody")],
556 )],
557 };
558 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
559 match d {
560 SshDecision::Accept(a) => assert_eq!(a.local_user, "rootlocal"),
561 other => panic!("expected accept, got {other:?}"),
562 }
563 }
564
565 #[test]
566 fn principal_matches_by_stable_id() {
567 let pol = SshPolicy {
568 rules: vec![accept_rule(
569 vec![SshPrincipal {
570 node: "nABC".to_string(),
571 ..Default::default()
572 }],
573 &[("*", "=")],
574 )],
575 };
576 let yes = pol.evaluate(&id("nABC", "100.64.0.9", None), "u", now());
577 assert!(matches!(yes, SshDecision::Accept(_)));
578 let no = pol.evaluate(&id("nXYZ", "100.64.0.9", None), "u", now());
579 assert_eq!(no, SshDecision::Deny(SshDenyReason::NoRuleMatched));
580 }
581
582 #[test]
583 fn principal_matches_by_node_ip() {
584 let pol = SshPolicy {
585 rules: vec![accept_rule(
586 vec![SshPrincipal {
587 node_ip: "100.64.0.7".to_string(),
588 ..Default::default()
589 }],
590 &[("*", "=")],
591 )],
592 };
593 let yes = pol.evaluate(&id("n1", "100.64.0.7", None), "u", now());
594 assert!(matches!(yes, SshDecision::Accept(_)));
595 let no = pol.evaluate(&id("n1", "100.64.0.8", None), "u", now());
596 assert_eq!(no, SshDecision::Deny(SshDenyReason::NoRuleMatched));
597 }
598
599 #[test]
600 fn principal_matches_by_user_login() {
601 let pol = SshPolicy {
602 rules: vec![accept_rule(
603 vec![SshPrincipal {
604 user_login: "alice@example.com".to_string(),
605 ..Default::default()
606 }],
607 &[("*", "=")],
608 )],
609 };
610 let yes = pol.evaluate(
611 &id("n1", "100.64.0.1", Some("alice@example.com")),
612 "u",
613 now(),
614 );
615 assert!(matches!(yes, SshDecision::Accept(_)));
616 let no = pol.evaluate(&id("n1", "100.64.0.1", None), "u", now());
618 assert_eq!(no, SshDecision::Deny(SshDenyReason::NoRuleMatched));
619 }
620
621 #[test]
622 fn all_empty_non_any_principal_matches_nothing() {
623 let pol = SshPolicy {
624 rules: vec![accept_rule(vec![SshPrincipal::default()], &[("*", "=")])],
625 };
626 let d = pol.evaluate(&id("n1", "100.64.0.1", Some("a@b")), "u", now());
627 assert_eq!(d, SshDecision::Deny(SshDenyReason::NoRuleMatched));
628 }
629
630 #[test]
631 fn explicit_reject_short_circuits_before_user_mapping() {
632 let pol = SshPolicy {
634 rules: vec![SshRule {
635 principals: vec![any_principal()],
636 action: Some(SshAction {
637 reject: true,
638 message: "go away".to_string(),
639 ..Default::default()
640 }),
641 ..Default::default()
642 }],
643 };
644 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
645 assert_eq!(
646 d,
647 SshDecision::Deny(SshDenyReason::ExplicitReject {
648 message: "go away".to_string()
649 })
650 );
651 }
652
653 #[test]
654 fn first_matching_rule_wins() {
655 let pol = SshPolicy {
657 rules: vec![
658 SshRule {
659 principals: vec![any_principal()],
660 action: Some(SshAction {
661 reject: true,
662 ..Default::default()
663 }),
664 ..Default::default()
665 },
666 accept_rule(vec![any_principal()], &[("*", "=")]),
667 ],
668 };
669 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
670 assert!(matches!(
671 d,
672 SshDecision::Deny(SshDenyReason::ExplicitReject { .. })
673 ));
674 }
675
676 #[test]
677 fn rule_with_no_action_is_skipped() {
678 let pol = SshPolicy {
679 rules: vec![
680 SshRule {
681 principals: vec![any_principal()],
682 action: None,
683 ..Default::default()
684 },
685 accept_rule(vec![any_principal()], &[("*", "=")]),
686 ],
687 };
688 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
689 assert!(matches!(d, SshDecision::Accept(_)));
690 }
691
692 #[test]
693 fn expired_rule_is_skipped() {
694 let past = "2000-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
695 let pol = SshPolicy {
696 rules: vec![SshRule {
697 rule_expires: Some(past),
698 ..accept_rule(vec![any_principal()], &[("*", "=")])
699 }],
700 };
701 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
702 assert_eq!(d, SshDecision::Deny(SshDenyReason::NoRuleMatched));
703 }
704
705 #[test]
706 fn unexpired_rule_still_matches() {
707 let future = "2999-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
708 let pol = SshPolicy {
709 rules: vec![SshRule {
710 rule_expires: Some(future),
711 ..accept_rule(vec![any_principal()], &[("*", "=")])
712 }],
713 };
714 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
715 assert!(matches!(d, SshDecision::Accept(_)));
716 }
717
718 #[test]
719 fn evaluate_at_unix_far_future_expires_time_limited_rules() {
720 let future = "2999-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
723 let pol = SshPolicy {
724 rules: vec![SshRule {
725 rule_expires: Some(future),
726 ..accept_rule(vec![any_principal()], &[("*", "=")])
727 }],
728 };
729 let d = pol.evaluate_at_unix(&id("n1", "100.64.0.1", None), "root", i64::MAX);
730 assert_eq!(d, SshDecision::Deny(SshDenyReason::NoRuleMatched));
731 }
732
733 #[test]
734 fn session_duration_zero_is_unlimited() {
735 let serde_action = ts_control_serde::SSHAction {
736 accept: true,
737 session_duration: Some(0),
738 ..Default::default()
739 };
740 assert_eq!(
741 SshAction::from_serde(&serde_action).session_duration_nanos,
742 None
743 );
744 }
745
746 #[test]
747 fn from_serde_round_trips_a_policy() {
748 let wire = r#"{
749 "rules": [
750 {
751 "principals": [{ "any": true }],
752 "sshUsers": { "*": "=" },
753 "action": { "accept": true, "allowAgentForwarding": true }
754 }
755 ]
756 }"#;
757 let serde_pol: ts_control_serde::SSHPolicy = serde_json::from_str(wire).unwrap();
758 let pol = SshPolicy::from_serde(&serde_pol);
759
760 let d = pol.evaluate(&id("n1", "100.64.0.1", None), "ubuntu", now());
761 match d {
762 SshDecision::Accept(a) => {
763 assert_eq!(a.local_user, "ubuntu");
764 assert!(a.allow_agent_forwarding);
765 }
766 other => panic!("expected accept, got {other:?}"),
767 }
768 }
769
770 #[test]
776 fn recorders_set_marks_accept_recording_required() {
777 let recorder: SocketAddr = "1.2.3.4:5678".parse().unwrap();
778 let pol = SshPolicy {
779 rules: vec![SshRule {
780 action: Some(SshAction {
781 accept: true,
782 recorders: vec![recorder],
783 ..Default::default()
784 }),
785 ..accept_rule(vec![any_principal()], &[("*", "=")])
786 }],
787 };
788 match pol.evaluate(&id("n1", "100.64.0.1", None), "root", now()) {
789 SshDecision::Accept(a) => {
790 assert!(a.recording_required, "recorders set must demand recording");
791 assert_eq!(a.recorders, vec![recorder]);
792 }
793 other => panic!("expected accept, got {other:?}"),
794 }
795 }
796
797 #[test]
800 fn no_recorders_accept_is_not_recording_required() {
801 let pol = SshPolicy {
802 rules: vec![accept_rule(vec![any_principal()], &[("*", "=")])],
803 };
804 match pol.evaluate(&id("n1", "100.64.0.1", None), "root", now()) {
805 SshDecision::Accept(a) => {
806 assert!(
807 !a.recording_required,
808 "no recorders must not demand recording"
809 );
810 assert!(a.recorders.is_empty());
811 assert!(a.recording_refusal_message.is_empty());
812 }
813 other => panic!("expected accept, got {other:?}"),
814 }
815 }
816
817 #[test]
820 fn hold_and_delegate_marks_accept_recording_required() {
821 let pol = SshPolicy {
822 rules: vec![SshRule {
823 action: Some(SshAction {
824 accept: true,
825 hold_and_delegate: "https://control.example/ssh/action/xyz".to_string(),
826 ..Default::default()
827 }),
828 ..accept_rule(vec![any_principal()], &[("*", "=")])
829 }],
830 };
831 match pol.evaluate(&id("n1", "100.64.0.1", None), "root", now()) {
832 SshDecision::Accept(a) => {
833 assert!(
834 a.recording_required,
835 "holdAndDelegate must be enforced fail-closed (not silently accepted)"
836 );
837 }
838 other => panic!("expected accept, got {other:?}"),
839 }
840 }
841
842 #[test]
846 fn from_serde_carries_recorders_and_on_recording_failure() {
847 let wire = r#"{
848 "rules": [
849 {
850 "principals": [{ "any": true }],
851 "sshUsers": { "*": "=" },
852 "action": {
853 "accept": true,
854 "recorders": ["1.2.3.4:5678", "5.6.7.8:9000"],
855 "onRecordingFailure": {
856 "rejectSessionWithMessage": "recording required by policy",
857 "notifyURL": "https://example.com/notify"
858 }
859 }
860 }
861 ]
862 }"#;
863 let serde_pol: ts_control_serde::SSHPolicy = serde_json::from_str(wire).unwrap();
864 let pol = SshPolicy::from_serde(&serde_pol);
865
866 let action = pol.rules[0].action.as_ref().unwrap();
868 assert_eq!(
869 action.recorders,
870 vec![
871 "1.2.3.4:5678".parse::<SocketAddr>().unwrap(),
872 "5.6.7.8:9000".parse::<SocketAddr>().unwrap(),
873 ]
874 );
875 let orf = action.on_recording_failure.as_ref().unwrap();
876 assert_eq!(
877 orf.reject_session_with_message,
878 "recording required by policy"
879 );
880 assert_eq!(orf.notify_url, "https://example.com/notify");
881
882 match pol.evaluate(&id("n1", "100.64.0.1", None), "root", now()) {
884 SshDecision::Accept(a) => {
885 assert!(a.recording_required);
886 assert_eq!(a.recording_refusal_message, "recording required by policy");
887 }
888 other => panic!("expected accept, got {other:?}"),
889 }
890 }
891
892 #[test]
895 fn recording_refusal_message_falls_back_to_action_message() {
896 let action = SshAction {
897 accept: true,
898 message: "see your admin".to_string(),
899 recorders: vec!["1.2.3.4:5678".parse().unwrap()],
900 ..Default::default()
901 };
902 assert_eq!(action.recording_refusal_message(), "see your admin");
903
904 let action = SshAction {
906 on_recording_failure: Some(SshRecorderFailureAction::default()),
907 ..action
908 };
909 assert_eq!(action.recording_refusal_message(), "see your admin");
910 }
911}