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, SocketAddr};
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`.
82///
83/// Recording (`recorders` / `on_recording_failure`) and the interactive `hold_and_delegate`
84/// control round-trip are carried through from the wire so the server can enforce them
85/// fail-closed. This fork has **no recorder transport and no delegate round-trip yet**, so a rule
86/// that *demands* either (non-empty `recorders`, or a non-empty `hold_and_delegate`) cannot be
87/// honored and the session is refused rather than silently downgraded to a plain accept.
88#[derive(Default, Debug, Clone, PartialEq, Eq)]
89pub struct SshAction {
90    /// Optional message shown to the user.
91    pub message: String,
92    /// Reject the connection.
93    pub reject: bool,
94    /// Accept the connection.
95    pub accept: bool,
96    /// Max session duration in **nanoseconds** (`None`/`0` = unlimited).
97    pub session_duration_nanos: Option<i64>,
98    /// Allow SSH agent forwarding.
99    pub allow_agent_forwarding: bool,
100    /// Allow local port forwarding.
101    pub allow_local_port_forwarding: bool,
102    /// Allow remote port forwarding.
103    pub allow_remote_port_forwarding: bool,
104    /// Session recorders (`ip:port`) this session must be streamed to. A **non-empty** list means
105    /// the policy *demands* recording; mirrors `tailcfg.SSHAction.Recorders`.
106    pub recorders: Vec<SocketAddr>,
107    /// What to do when recording cannot be performed; mirrors `tailcfg.SSHAction.OnRecordingFailure`.
108    /// `None` is Go's "ignore recording failures" (fail-open). The interim server still refuses
109    /// when it has no recorder transport at all — see [`SshAccept::recording_required`].
110    pub on_recording_failure: Option<SshRecorderFailureAction>,
111    /// If non-empty, the rule wants the final decision delegated to this URL over a control
112    /// round-trip (Go `HoldAndDelegate`). Carried for fidelity; this fork does **not** perform the
113    /// delegate fetch, so a rule bearing it is treated as not-yet-supported and denied (fail-closed)
114    /// rather than silently accepted. Mirrors `tailcfg.SSHAction.HoldAndDelegate`.
115    // TODO(tsr-0h2 follow-up): implement the HoldAndDelegate check-mode round-trip (needs a live
116    // Noise control channel the turnkey `listen_ssh` server does not currently have); until then a
117    // `hold_and_delegate`-bearing rule is denied with a clear message instead of accepted.
118    pub hold_and_delegate: String,
119}
120
121/// What to do when session recording fails for an [`SshAction`] that has recorders configured.
122/// Mirrors `tailcfg.SSHRecorderFailureAction`.
123#[derive(Default, Debug, Clone, PartialEq, Eq)]
124pub struct SshRecorderFailureAction {
125    /// If non-empty, refuse the session with this message when recording cannot start. This is
126    /// Go's explicit **fail-closed** signal (`RejectSessionWithMessage`).
127    pub reject_session_with_message: String,
128    /// If non-empty, terminate an in-progress session with this message when recording fails.
129    pub terminate_session_with_message: String,
130    /// If non-empty, a URL to notify out-of-band when recording fails.
131    pub notify_url: String,
132}
133
134/// The identity of an incoming SSH connection, resolved from the connecting peer.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct SshConnIdentity {
137    /// The connecting node's stable node id.
138    pub stable_id: String,
139    /// The connection's tailnet source IP.
140    pub src_ip: IpAddr,
141    /// The login/email of the user that owns the connecting node, if known. `None` means no
142    /// `userLogin` principal can match — fail-closed.
143    pub user_login: Option<String>,
144}
145
146/// The outcome of evaluating an [`SshPolicy`] against a connection.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum SshDecision {
149    /// A rule matched with an accept action; allow the connection.
150    Accept(SshAccept),
151    /// The connection is denied. The server denies in every case; the reason aids logging.
152    Deny(SshDenyReason),
153}
154
155/// Details of an accepted SSH connection.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct SshAccept {
158    /// The resolved local Unix user to run the session as.
159    pub local_user: String,
160    /// Environment variable names the client may forward.
161    pub accept_env: Vec<String>,
162    /// Max session duration in nanoseconds (`None`/`0` = unlimited).
163    pub session_duration_nanos: Option<i64>,
164    /// Whether SSH agent forwarding is permitted.
165    pub allow_agent_forwarding: bool,
166    /// Whether local port forwarding is permitted.
167    pub allow_local_port_forwarding: bool,
168    /// Whether remote port forwarding is permitted.
169    pub allow_remote_port_forwarding: bool,
170    /// The session recorders (`ip:port`) the matched rule demands this session be streamed to.
171    /// Empty for the common no-recording case.
172    pub recorders: Vec<SocketAddr>,
173    /// `true` when the matched rule **demands** a capability this fork cannot yet provide, so the
174    /// server MUST refuse the session rather than accept it un-recorded/un-delegated (the tsr-0h2
175    /// fail-closed gate). Set when `recorders` is non-empty (recording demanded but there is no
176    /// recorder transport) or `hold_and_delegate` is set (delegate round-trip unimplemented).
177    ///
178    /// The common case — no recorders, no delegate — leaves this `false`, so those sessions accept
179    /// normally and the gate is a no-op for them.
180    pub recording_required: bool,
181    /// The message to surface when refusing a `recording_required` session: the policy's
182    /// `on_recording_failure.reject_session_with_message` when set (Go's explicit fail-closed
183    /// message), else the action `message`, else empty (the caller substitutes a default).
184    pub recording_refusal_message: String,
185}
186
187/// Why a connection was denied. Mirrors Go's `rejected` / `rejectedUser` results plus an explicit
188/// reject action.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum SshDenyReason {
191    /// A rule matched with an explicit reject action (carries its message).
192    ExplicitReject {
193        /// The action's message, if any.
194        message: String,
195    },
196    /// No rule matched the connection (Go `rejected`). Default-deny.
197    NoRuleMatched,
198    /// A rule's principals matched but no SSH-user mapping applied (Go `rejectedUser`).
199    NoUserMapping,
200}
201
202/// Internal per-rule match failure, mirroring Go's `matchRule` error set. Only `UserMatch` is
203/// surfaced (to distinguish [`SshDenyReason::NoUserMapping`]); the rest just skip the rule.
204enum RuleSkip {
205    /// Rule has no action, is expired, or no principal matched.
206    NoMatch,
207    /// Principals matched but the user-map produced no local user (Go `errUserMatch`).
208    UserMatch,
209}
210
211impl SshPolicy {
212    /// Build the owned policy from the borrowed wire view parsed off the netmap.
213    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    /// Evaluate this policy as of a wall-clock time given in **Unix seconds**.
220    ///
221    /// Convenience wrapper over [`evaluate`](Self::evaluate) for callers that cannot construct a
222    /// `chrono::DateTime<Utc>` (the workspace pins `chrono` without its `clock` feature, so
223    /// `Utc::now()` is unavailable outside crates that carry chrono). An out-of-range timestamp is
224    /// clamped to the Unix epoch — for rule-expiry that at worst treats a rule as already-expired
225    /// (fail-closed).
226    pub fn evaluate_at_unix(
227        &self,
228        id: &SshConnIdentity,
229        requested_user: &str,
230        now_unix_secs: i64,
231    ) -> SshDecision {
232        // An out-of-`DateTime`-range timestamp (e.g. the `i64::MAX` a caller uses to signal an
233        // unreadable clock) clamps to the far future so time-limited rules look expired — deny,
234        // fail-closed. Do NOT clamp to the epoch (`unwrap_or_default`), which would make every
235        // future-dated rule look live (fail-open).
236        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    /// Evaluate this policy against an incoming connection requesting `requested_user`, as of
241    /// `now`. Returns the first matching rule's outcome, or a default-deny.
242    ///
243    /// This is the Rust analogue of Go `evalSSHPolicy`: first-match-wins over the ordered rules,
244    /// default-deny when nothing matches.
245    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    /// Mirror of Go `matchRule`: validate action/expiry/principals/user-mapping in order.
285    fn try_match(
286        &self,
287        id: &SshConnIdentity,
288        requested_user: &str,
289        now: DateTime<Utc>,
290    ) -> Result<SshDecision, RuleSkip> {
291        // A rule with no action never matches (Go `errNilAction`).
292        let action = self.action.as_ref().ok_or(RuleSkip::NoMatch)?;
293
294        // Expired rules never match (Go `ruleExpired`: nil never expires).
295        if self.is_expired(now) {
296            return Err(RuleSkip::NoMatch);
297        }
298
299        // Some principal must match the connection identity (Go `anyPrincipalMatches`).
300        if !self.principals.iter().any(|p| p.matches(id)) {
301            return Err(RuleSkip::NoMatch);
302        }
303
304        // An explicit reject short-circuits before user mapping (Go skips the user requirement for
305        // reject actions).
306        if action.reject {
307            return Ok(SshDecision::Deny(SshDenyReason::ExplicitReject {
308                message: action.message.clone(),
309            }));
310        }
311
312        // Non-reject rules require a non-empty local-user mapping (Go `errUserMatch` otherwise).
313        let local_user =
314            map_local_user(&self.ssh_users, requested_user).ok_or(RuleSkip::UserMatch)?;
315
316        // SECURITY (tsr-0h2): a matched accept rule that DEMANDS a capability this fork cannot
317        // provide must not be silently downgraded to a plain accept. Recording is demanded when the
318        // rule carries recorders; HoldAndDelegate is demanded when its URL is set. Neither has a
319        // transport here yet, so flag the accept as `recording_required` and let the server refuse
320        // it (fail-closed). The empty-recorders / no-delegate path leaves the flag false → normal
321        // accept, so the common case is untouched.
322        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    /// Mirror of Go `principalMatchesTailscaleIdentity`: `Any`, or any populated field matching the
358    /// connection identity. Empty principal fields never match (so an all-empty principal that is
359    /// not `any` matches nothing — fail-closed).
360    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            // Go marshals 0 as omitted; treat 0 as "no limit" too.
394            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            // SECURITY: carry the recording/delegate intent into the domain. Previously these were
399            // parsed off the wire but DROPPED here, silently downgrading a "record-or-refuse" rule
400            // to a plain accept (the tsr-0h2 bypass). The server gate now refuses such sessions.
401            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    /// The message to surface when a session this action describes must be refused for lack of a
411    /// recorder/delegate transport. Prefers the policy's explicit fail-closed message
412    /// (`on_recording_failure.reject_session_with_message`), then the action `message`, else empty
413    /// (the server substitutes a sensible default). Empty strings are skipped so a present-but-blank
414    /// field does not mask a useful fallback.
415    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
435/// Mirror of Go `mapLocalUser`: look up the requested user, falling back to the `"*"` wildcard. A
436/// `"="` value maps to the requested user verbatim; an empty-string value (or no entry) yields
437/// `None` (no mapping → the rule does not apply to this user).
438fn 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    // A fixed "now" for evaluation; chrono's `clock` feature (Utc::now) isn't enabled here.
463    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        // An empty-string mapping means the rule does NOT apply to that user. Since principals
532        // matched but no user mapping applied, the final deny reason is NoUserMapping.
533        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        // Requested "root" with only a non-wildcard "alice" entry: no mapping, principals matched.
543        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        // Unknown login (None) can never match a userLogin principal — fail-closed.
617        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        // Reject rule with NO ssh_users mapping still rejects (user mapping is skipped for reject).
633        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        // A reject rule before an accept rule wins.
656        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        // A broken clock surfaces as i64::MAX seconds; a time-limited rule must then look expired
721        // (deny) rather than perpetually-live — fail-closed expiry.
722        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    // ---- tsr-0h2: recording / hold-and-delegate are carried, not dropped ----
771
772    /// A rule that demands recording (non-empty `recorders`) yields an accept flagged
773    /// `recording_required` — the signal the server uses to refuse (close the bypass). Without
774    /// recorders the same rule must accept normally (`recording_required == false`).
775    #[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    /// Regression guard for the common case: a normal accept rule with NO recorders must NOT be
798    /// flagged `recording_required`, so the fail-closed gate never touches it.
799    #[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    /// A `holdAndDelegate`-bearing rule is treated as not-yet-supported: carried through and flagged
818    /// `recording_required` (the fail-closed signal) rather than silently accepted.
819    #[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    /// `from_serde` must carry `recorders` and `onRecordingFailure` into the domain (the fields
843    /// were previously parsed off the wire but DROPPED — the tsr-0h2 bypass). The refusal message
844    /// prefers `rejectSessionWithMessage`.
845    #[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        // The domain action retained the recording fields.
867        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        // And evaluation surfaces the fail-closed signal + the explicit refusal message.
883        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    /// Refusal-message precedence: with no `rejectSessionWithMessage`, fall back to the action
893    /// `message`.
894    #[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        // An empty rejectSessionWithMessage must NOT mask the action message fallback.
905        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}