Skip to main content

ts_control/
ssh_policy.rs

1//! Owned domain model + evaluation engine for Tailscale SSH policy.
2//!
3//! Control pushes an [`ts_control_serde::SSHPolicy`] down the netmap
4//! ([`MapResponse::ssh_policy`][ts_control_serde::MapResponse::ssh_policy]). This module converts
5//! the borrowed wire view into an owned [`SshPolicy`] and provides [`SshPolicy::evaluate`], a
6//! faithful reimplementation of the Go client's `evalSSHPolicy` / `matchRule` / `mapLocalUser`
7//! decision flow (`ssh/tailssh/tailssh.go`). An incoming SSH connection is allowed **only** when a
8//! rule matches; the engine is **default-deny**.
9//!
10//! ## Go decision flow mirrored here
11//!
12//! `evalSSHPolicy` walks the rules in order and returns the outcome of the **first** rule that
13//! matches (`matchRule` returns `Ok`). `matchRule`:
14//! 1. requires a non-nil [`SshAction`] (a rule with no action never matches),
15//! 2. rejects expired rules (`RuleExpires.Before(now)`),
16//! 3. requires that **some** principal matches the connection identity, and
17//! 4. for non-reject actions, requires a non-empty local-user mapping (else the rule is skipped
18//!    with a "user match" failure — Go's `errUserMatch`).
19//!
20//! If no rule matches, the connection is denied. Go distinguishes a plain no-match
21//! ([`SshDenyReason::NoRuleMatched`]) from "principals matched but no user mapping applied"
22//! ([`SshDenyReason::NoUserMapping`]); both deny, the distinction is kept for diagnostics.
23//!
24//! ## `SSHUsers` map semantics (verbatim Go)
25//!
26//! [`SshRule::ssh_users`] maps a **requested** SSH username to the **local** user the session runs
27//! as. Lookup is the requested user, falling back to the wildcard key `"*"`. A value of `"="` means
28//! "use the requested username as-is"; an **empty-string** value means the rule does **not** apply
29//! to that user (no mapping → skip the rule).
30
31use alloc::{
32    collections::BTreeMap,
33    string::{String, ToString},
34    vec::Vec,
35};
36use core::net::IpAddr;
37
38use chrono::{DateTime, Utc};
39
40/// The wildcard SSH-user key: matches any requested username.
41const WILDCARD_USER: &str = "*";
42/// The "use the requested username as-is" SSH-user mapping value.
43const IDENTITY_MAP: &str = "=";
44
45/// An owned Tailscale SSH policy. Mirrors `tailcfg.SSHPolicy`.
46#[derive(Default, Debug, Clone, PartialEq, Eq)]
47pub struct SshPolicy {
48    /// Rules evaluated in order; the first matching rule decides the connection.
49    pub rules: Vec<SshRule>,
50}
51
52/// A single SSH policy rule. Mirrors `tailcfg.SSHRule`.
53#[derive(Default, Debug, Clone, PartialEq, Eq)]
54pub struct SshRule {
55    /// If set, the rule no longer matches once `now` is at/after this time.
56    pub rule_expires: Option<DateTime<Utc>>,
57    /// Principals; the rule matches a connection if **any** of these match it.
58    pub principals: Vec<SshPrincipal>,
59    /// Requested-SSH-user → local-user mapping. See module docs for `"*"` / `"="` / empty semantics.
60    pub ssh_users: BTreeMap<String, String>,
61    /// The action to take when this rule matches. `None` means the rule never matches.
62    pub action: Option<SshAction>,
63    /// Allowlist of environment variable names the client may forward.
64    pub accept_env: Vec<String>,
65}
66
67/// A principal an [`SshRule`] matches against. Mirrors `tailcfg.SSHPrincipal`. A principal matches
68/// if [`any`](SshPrincipal::any) is set, or any populated field matches the connection identity.
69#[derive(Default, Debug, Clone, PartialEq, Eq)]
70pub struct SshPrincipal {
71    /// Match a specific node by its stable node id.
72    pub node: String,
73    /// Match a node by one of its Tailscale IPs (parsed from this string).
74    pub node_ip: String,
75    /// Match a node owned by a particular user login (email-ish).
76    pub user_login: String,
77    /// Match any source.
78    pub any: bool,
79}
80
81/// The action taken when a rule matches. Mirrors `tailcfg.SSHAction`. Only the fields this fork
82/// acts on are retained; recording (`Recorders`/`OnRecordingFailure`) and the interactive
83/// `HoldAndDelegate` control round-trip are out of scope for basic `ListenSSH` parity.
84#[derive(Default, Debug, Clone, PartialEq, Eq)]
85pub struct SshAction {
86    /// Optional message shown to the user.
87    pub message: String,
88    /// Reject the connection.
89    pub reject: bool,
90    /// Accept the connection.
91    pub accept: bool,
92    /// Max session duration in **nanoseconds** (`None`/`0` = unlimited).
93    pub session_duration_nanos: Option<i64>,
94    /// Allow SSH agent forwarding.
95    pub allow_agent_forwarding: bool,
96    /// Allow local port forwarding.
97    pub allow_local_port_forwarding: bool,
98    /// Allow remote port forwarding.
99    pub allow_remote_port_forwarding: bool,
100}
101
102/// The identity of an incoming SSH connection, resolved from the connecting peer.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct SshConnIdentity {
105    /// The connecting node's stable node id.
106    pub stable_id: String,
107    /// The connection's tailnet source IP.
108    pub src_ip: IpAddr,
109    /// The login/email of the user that owns the connecting node, if known. `None` means no
110    /// `userLogin` principal can match — fail-closed.
111    pub user_login: Option<String>,
112}
113
114/// The outcome of evaluating an [`SshPolicy`] against a connection.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum SshDecision {
117    /// A rule matched with an accept action; allow the connection.
118    Accept(SshAccept),
119    /// The connection is denied. The server denies in every case; the reason aids logging.
120    Deny(SshDenyReason),
121}
122
123/// Details of an accepted SSH connection.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct SshAccept {
126    /// The resolved local Unix user to run the session as.
127    pub local_user: String,
128    /// Environment variable names the client may forward.
129    pub accept_env: Vec<String>,
130    /// Max session duration in nanoseconds (`None`/`0` = unlimited).
131    pub session_duration_nanos: Option<i64>,
132    /// Whether SSH agent forwarding is permitted.
133    pub allow_agent_forwarding: bool,
134    /// Whether local port forwarding is permitted.
135    pub allow_local_port_forwarding: bool,
136    /// Whether remote port forwarding is permitted.
137    pub allow_remote_port_forwarding: bool,
138}
139
140/// Why a connection was denied. Mirrors Go's `rejected` / `rejectedUser` results plus an explicit
141/// reject action.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum SshDenyReason {
144    /// A rule matched with an explicit reject action (carries its message).
145    ExplicitReject {
146        /// The action's message, if any.
147        message: String,
148    },
149    /// No rule matched the connection (Go `rejected`). Default-deny.
150    NoRuleMatched,
151    /// A rule's principals matched but no SSH-user mapping applied (Go `rejectedUser`).
152    NoUserMapping,
153}
154
155/// Internal per-rule match failure, mirroring Go's `matchRule` error set. Only `UserMatch` is
156/// surfaced (to distinguish [`SshDenyReason::NoUserMapping`]); the rest just skip the rule.
157enum RuleSkip {
158    /// Rule has no action, is expired, or no principal matched.
159    NoMatch,
160    /// Principals matched but the user-map produced no local user (Go `errUserMatch`).
161    UserMatch,
162}
163
164impl SshPolicy {
165    /// Build the owned policy from the borrowed wire view parsed off the netmap.
166    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    /// Evaluate this policy as of a wall-clock time given in **Unix seconds**.
173    ///
174    /// Convenience wrapper over [`evaluate`](Self::evaluate) for callers that cannot construct a
175    /// `chrono::DateTime<Utc>` (the workspace pins `chrono` without its `clock` feature, so
176    /// `Utc::now()` is unavailable outside crates that carry chrono). An out-of-range timestamp is
177    /// clamped to the Unix epoch — for rule-expiry that at worst treats a rule as already-expired
178    /// (fail-closed).
179    pub fn evaluate_at_unix(
180        &self,
181        id: &SshConnIdentity,
182        requested_user: &str,
183        now_unix_secs: i64,
184    ) -> SshDecision {
185        // An out-of-`DateTime`-range timestamp (e.g. the `i64::MAX` a caller uses to signal an
186        // unreadable clock) clamps to the far future so time-limited rules look expired — deny,
187        // fail-closed. Do NOT clamp to the epoch (`unwrap_or_default`), which would make every
188        // future-dated rule look live (fail-open).
189        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    /// Evaluate this policy against an incoming connection requesting `requested_user`, as of
194    /// `now`. Returns the first matching rule's outcome, or a default-deny.
195    ///
196    /// This is the Rust analogue of Go `evalSSHPolicy`: first-match-wins over the ordered rules,
197    /// default-deny when nothing matches.
198    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    /// Mirror of Go `matchRule`: validate action/expiry/principals/user-mapping in order.
238    fn try_match(
239        &self,
240        id: &SshConnIdentity,
241        requested_user: &str,
242        now: DateTime<Utc>,
243    ) -> Result<SshDecision, RuleSkip> {
244        // A rule with no action never matches (Go `errNilAction`).
245        let action = self.action.as_ref().ok_or(RuleSkip::NoMatch)?;
246
247        // Expired rules never match (Go `ruleExpired`: nil never expires).
248        if self.is_expired(now) {
249            return Err(RuleSkip::NoMatch);
250        }
251
252        // Some principal must match the connection identity (Go `anyPrincipalMatches`).
253        if !self.principals.iter().any(|p| p.matches(id)) {
254            return Err(RuleSkip::NoMatch);
255        }
256
257        // An explicit reject short-circuits before user mapping (Go skips the user requirement for
258        // reject actions).
259        if action.reject {
260            return Ok(SshDecision::Deny(SshDenyReason::ExplicitReject {
261                message: action.message.clone(),
262            }));
263        }
264
265        // Non-reject rules require a non-empty local-user mapping (Go `errUserMatch` otherwise).
266        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    /// Mirror of Go `principalMatchesTailscaleIdentity`: `Any`, or any populated field matching the
298    /// connection identity. Empty principal fields never match (so an all-empty principal that is
299    /// not `any` matches nothing — fail-closed).
300    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            // Go marshals 0 as omitted; treat 0 as "no limit" too.
334            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
342/// Mirror of Go `mapLocalUser`: look up the requested user, falling back to the `"*"` wildcard. A
343/// `"="` value maps to the requested user verbatim; an empty-string value (or no entry) yields
344/// `None` (no mapping → the rule does not apply to this user).
345fn 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    // A fixed "now" for evaluation; chrono's `clock` feature (Utc::now) isn't enabled here.
370    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        // An empty-string mapping means the rule does NOT apply to that user. Since principals
439        // matched but no user mapping applied, the final deny reason is NoUserMapping.
440        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        // Requested "root" with only a non-wildcard "alice" entry: no mapping, principals matched.
450        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        // Unknown login (None) can never match a userLogin principal — fail-closed.
524        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        // Reject rule with NO ssh_users mapping still rejects (user mapping is skipped for reject).
540        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        // A reject rule before an accept rule wins.
563        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        // A broken clock surfaces as i64::MAX seconds; a time-limited rule must then look expired
628        // (deny) rather than perpetually-live — fail-closed expiry.
629        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}