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. Recording
85/// is now implemented (`tailscale::ssh::recording` streams the session to the recorders and
86/// applies `on_recording_failure`); `hold_and_delegate` still has **no delegate round-trip**, so a
87/// rule bearing it 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).
109    pub on_recording_failure: Option<SshRecorderFailureAction>,
110    /// If non-empty, the rule wants the final decision delegated to this URL over a control
111    /// round-trip (Go `HoldAndDelegate`). Carried for fidelity; this fork does **not** perform the
112    /// delegate fetch, so a rule bearing it is treated as not-yet-supported and denied (fail-closed)
113    /// rather than silently accepted. Mirrors `tailcfg.SSHAction.HoldAndDelegate`.
114    // TODO: implement the HoldAndDelegate check-mode round-trip (needs a live Noise control
115    // channel the turnkey `listen_ssh` server does not currently have); until then a
116    // `hold_and_delegate`-bearing rule is denied with a clear message instead of accepted.
117    pub hold_and_delegate: String,
118}
119
120/// What to do when session recording fails for an [`SshAction`] that has recorders configured.
121/// Mirrors `tailcfg.SSHRecorderFailureAction`.
122#[derive(Default, Debug, Clone, PartialEq, Eq)]
123pub struct SshRecorderFailureAction {
124    /// If non-empty, refuse the session with this message when recording cannot start. This is
125    /// Go's explicit **fail-closed** signal (`RejectSessionWithMessage`).
126    pub reject_session_with_message: String,
127    /// If non-empty, terminate an in-progress session with this message when recording fails.
128    pub terminate_session_with_message: String,
129    /// If non-empty, a URL to notify out-of-band when recording fails.
130    pub notify_url: String,
131}
132
133/// The identity of an incoming SSH connection, resolved from the connecting peer.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct SshConnIdentity {
136    /// The connecting node's stable node id.
137    pub stable_id: String,
138    /// The connection's tailnet source IP.
139    pub src_ip: IpAddr,
140    /// The login/email of the user that owns the connecting node, if known. `None` means no
141    /// `userLogin` principal can match — fail-closed.
142    pub user_login: Option<String>,
143}
144
145/// The outcome of evaluating an [`SshPolicy`] against a connection.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum SshDecision {
148    /// A rule matched with an accept action; allow the connection.
149    Accept(SshAccept),
150    /// The connection is denied. The server denies in every case; the reason aids logging.
151    Deny(SshDenyReason),
152}
153
154/// Details of an accepted SSH connection.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct SshAccept {
157    /// The resolved local Unix user to run the session as.
158    pub local_user: String,
159    /// Environment variable names the client may forward.
160    pub accept_env: Vec<String>,
161    /// Max session duration in nanoseconds (`None`/`0` = unlimited).
162    pub session_duration_nanos: Option<i64>,
163    /// Whether SSH agent forwarding is permitted.
164    pub allow_agent_forwarding: bool,
165    /// Whether local port forwarding is permitted.
166    pub allow_local_port_forwarding: bool,
167    /// Whether remote port forwarding is permitted.
168    pub allow_remote_port_forwarding: bool,
169    /// The session recorders (`ip:port`) the matched rule demands this session be streamed to.
170    /// Empty for the common no-recording case.
171    ///
172    /// A non-empty list obliges the server to record: `tailscale::ssh` dials these in order and
173    /// streams the session to the first one that accepts it.
174    pub recorders: Vec<SocketAddr>,
175    /// What the server must do when recording cannot be started or its upload fails; mirrors
176    /// `tailcfg.SSHAction.OnRecordingFailure`. `None` is Go's fail-open default.
177    pub on_recording_failure: Option<SshRecorderFailureAction>,
178    /// The `holdAndDelegate` URL the matched rule carries, or empty. **Non-empty means the rule
179    /// demands a capability this fork cannot provide** (there is no delegate round-trip), so the
180    /// server MUST refuse the session rather than accept it un-delegated. The common case leaves
181    /// it empty and the gate is a no-op.
182    pub hold_and_delegate: String,
183    /// The message to surface when the session must be refused for an action the server cannot
184    /// honor: the policy's `on_recording_failure.reject_session_with_message` when set (Go's
185    /// explicit fail-closed message), else the action `message`, else empty (the caller
186    /// substitutes a default).
187    pub recording_refusal_message: String,
188}
189
190/// Why a connection was denied. Mirrors Go's `rejected` / `rejectedUser` results plus an explicit
191/// reject action.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub enum SshDenyReason {
194    /// A rule matched with an explicit reject action (carries its message).
195    ExplicitReject {
196        /// The action's message, if any.
197        message: String,
198    },
199    /// No rule matched the connection (Go `rejected`). Default-deny.
200    NoRuleMatched,
201    /// A rule's principals matched but no SSH-user mapping applied (Go `rejectedUser`).
202    NoUserMapping,
203}
204
205/// Internal per-rule match failure, mirroring Go's `matchRule` error set. Only `UserMatch` is
206/// surfaced (to distinguish [`SshDenyReason::NoUserMapping`]); the rest just skip the rule.
207enum RuleSkip {
208    /// Rule has no action, is expired, or no principal matched.
209    NoMatch,
210    /// Principals matched but the user-map produced no local user (Go `errUserMatch`).
211    UserMatch,
212}
213
214impl SshPolicy {
215    /// Build the owned policy from the borrowed wire view parsed off the netmap.
216    pub fn from_serde(p: &ts_control_serde::SSHPolicy<'_>) -> Self {
217        SshPolicy {
218            rules: p.rules.iter().map(SshRule::from_serde).collect(),
219        }
220    }
221
222    /// Evaluate this policy as of a wall-clock time given in **Unix seconds**.
223    ///
224    /// Convenience wrapper over [`evaluate`](Self::evaluate) for callers that cannot construct a
225    /// `chrono::DateTime<Utc>` (the workspace pins `chrono` without its `clock` feature, so
226    /// `Utc::now()` is unavailable outside crates that carry chrono). An out-of-range timestamp is
227    /// clamped to the Unix epoch — for rule-expiry that at worst treats a rule as already-expired
228    /// (fail-closed).
229    pub fn evaluate_at_unix(
230        &self,
231        id: &SshConnIdentity,
232        requested_user: &str,
233        now_unix_secs: i64,
234    ) -> SshDecision {
235        // An out-of-`DateTime`-range timestamp (e.g. the `i64::MAX` a caller uses to signal an
236        // unreadable clock) clamps to the far future so time-limited rules look expired — deny,
237        // fail-closed. Do NOT clamp to the epoch (`unwrap_or_default`), which would make every
238        // future-dated rule look live (fail-open).
239        let now = DateTime::from_timestamp(now_unix_secs, 0).unwrap_or(DateTime::<Utc>::MAX_UTC);
240        self.evaluate(id, requested_user, now)
241    }
242
243    /// Evaluate this policy against an incoming connection requesting `requested_user`, as of
244    /// `now`. Returns the first matching rule's outcome, or a default-deny.
245    ///
246    /// This is the Rust analogue of Go `evalSSHPolicy`: first-match-wins over the ordered rules,
247    /// default-deny when nothing matches.
248    pub fn evaluate(
249        &self,
250        id: &SshConnIdentity,
251        requested_user: &str,
252        now: DateTime<Utc>,
253    ) -> SshDecision {
254        let mut failed_on_user = false;
255
256        for rule in &self.rules {
257            match rule.try_match(id, requested_user, now) {
258                Ok(decision) => return decision,
259                Err(RuleSkip::UserMatch) => failed_on_user = true,
260                Err(RuleSkip::NoMatch) => {}
261            }
262        }
263
264        SshDecision::Deny(if failed_on_user {
265            SshDenyReason::NoUserMapping
266        } else {
267            SshDenyReason::NoRuleMatched
268        })
269    }
270}
271
272impl SshRule {
273    fn from_serde(r: &ts_control_serde::SSHRule<'_>) -> Self {
274        SshRule {
275            rule_expires: r.rule_expires,
276            principals: r.principals.iter().map(SshPrincipal::from_serde).collect(),
277            ssh_users: r
278                .ssh_users
279                .iter()
280                .map(|(k, v)| (k.to_string(), v.to_string()))
281                .collect(),
282            action: r.action.as_ref().map(SshAction::from_serde),
283            accept_env: r.accept_env.iter().map(|s| s.to_string()).collect(),
284        }
285    }
286
287    /// Mirror of Go `matchRule`: validate action/expiry/principals/user-mapping in order.
288    fn try_match(
289        &self,
290        id: &SshConnIdentity,
291        requested_user: &str,
292        now: DateTime<Utc>,
293    ) -> Result<SshDecision, RuleSkip> {
294        // A rule with no action never matches (Go `errNilAction`).
295        let action = self.action.as_ref().ok_or(RuleSkip::NoMatch)?;
296
297        // Expired rules never match (Go `ruleExpired`: nil never expires).
298        if self.is_expired(now) {
299            return Err(RuleSkip::NoMatch);
300        }
301
302        // Some principal must match the connection identity (Go `anyPrincipalMatches`).
303        if !self.principals.iter().any(|p| p.matches(id)) {
304            return Err(RuleSkip::NoMatch);
305        }
306
307        // An explicit reject short-circuits before user mapping (Go skips the user requirement for
308        // reject actions).
309        if action.reject {
310            return Ok(SshDecision::Deny(SshDenyReason::ExplicitReject {
311                message: action.message.clone(),
312            }));
313        }
314
315        // Non-reject rules require a non-empty local-user mapping (Go `errUserMatch` otherwise).
316        let local_user =
317            map_local_user(&self.ssh_users, requested_user).ok_or(RuleSkip::UserMatch)?;
318
319        // SECURITY: a matched accept rule that demands recording carries its recorders and its
320        // `on_recording_failure` into the accept, so the server records the session (and applies
321        // Go's fail-open / fail-closed rules) instead of silently downgrading to a plain accept.
322        // `hold_and_delegate` still has no transport, so it is carried as the fail-closed signal.
323        let recording_refusal_message = action.recording_refusal_message();
324
325        Ok(SshDecision::Accept(SshAccept {
326            local_user,
327            accept_env: self.accept_env.clone(),
328            session_duration_nanos: action.session_duration_nanos,
329            allow_agent_forwarding: action.allow_agent_forwarding,
330            allow_local_port_forwarding: action.allow_local_port_forwarding,
331            allow_remote_port_forwarding: action.allow_remote_port_forwarding,
332            recorders: action.recorders.clone(),
333            on_recording_failure: action.on_recording_failure.clone(),
334            hold_and_delegate: action.hold_and_delegate.clone(),
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 because the
411    /// server cannot honor it. 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    // ---- recording / hold-and-delegate are carried, not dropped ----
771
772    /// A rule that demands recording carries its recorders (and its failure action) into the
773    /// accept, which is what obliges the server to stream the session to them.
774    #[test]
775    fn recorders_are_carried_into_the_accept() {
776        let recorder: SocketAddr = "1.2.3.4:5678".parse().unwrap();
777        let pol = SshPolicy {
778            rules: vec![SshRule {
779                action: Some(SshAction {
780                    accept: true,
781                    recorders: vec![recorder],
782                    on_recording_failure: Some(SshRecorderFailureAction {
783                        terminate_session_with_message: "recorder gone".to_string(),
784                        ..Default::default()
785                    }),
786                    ..Default::default()
787                }),
788                ..accept_rule(vec![any_principal()], &[("*", "=")])
789            }],
790        };
791        match pol.evaluate(&id("n1", "100.64.0.1", None), "root", now()) {
792            SshDecision::Accept(a) => {
793                assert_eq!(a.recorders, vec![recorder]);
794                assert_eq!(
795                    a.on_recording_failure
796                        .as_ref()
797                        .map(|f| f.terminate_session_with_message.as_str()),
798                    Some("recorder gone"),
799                    "the failure action must reach the server that has to apply it"
800                );
801                assert!(
802                    a.hold_and_delegate.is_empty(),
803                    "recording alone must not trip the fail-closed delegate gate"
804                );
805            }
806            other => panic!("expected accept, got {other:?}"),
807        }
808    }
809
810    /// Regression guard for the common case: a normal accept rule carries no recorders, no failure
811    /// action and no delegate, so neither the recorder transport nor the fail-closed gate is armed.
812    #[test]
813    fn plain_accept_carries_no_recording_obligation() {
814        let pol = SshPolicy {
815            rules: vec![accept_rule(vec![any_principal()], &[("*", "=")])],
816        };
817        match pol.evaluate(&id("n1", "100.64.0.1", None), "root", now()) {
818            SshDecision::Accept(a) => {
819                assert!(a.recorders.is_empty());
820                assert!(a.on_recording_failure.is_none());
821                assert!(a.hold_and_delegate.is_empty());
822                assert!(a.recording_refusal_message.is_empty());
823            }
824            other => panic!("expected accept, got {other:?}"),
825        }
826    }
827
828    /// A `holdAndDelegate`-bearing rule is treated as not-yet-supported: its URL is carried into
829    /// the accept, which is the server's fail-closed signal, rather than silently accepted.
830    #[test]
831    fn hold_and_delegate_is_carried_as_the_fail_closed_signal() {
832        let pol = SshPolicy {
833            rules: vec![SshRule {
834                action: Some(SshAction {
835                    accept: true,
836                    hold_and_delegate: "https://control.example/ssh/action/xyz".to_string(),
837                    ..Default::default()
838                }),
839                ..accept_rule(vec![any_principal()], &[("*", "=")])
840            }],
841        };
842        match pol.evaluate(&id("n1", "100.64.0.1", None), "root", now()) {
843            SshDecision::Accept(a) => {
844                assert_eq!(
845                    a.hold_and_delegate, "https://control.example/ssh/action/xyz",
846                    "holdAndDelegate must be enforced fail-closed (not silently accepted)"
847                );
848            }
849            other => panic!("expected accept, got {other:?}"),
850        }
851    }
852
853    /// `from_serde` must carry `recorders` and `onRecordingFailure` into the domain (the fields
854    /// were previously parsed off the wire but DROPPED — the tsr-0h2 bypass). The refusal message
855    /// prefers `RejectSessionWithMessage`. NB: the `onRecordingFailure` sub-object's fields are
856    /// PascalCase on the wire (Go declares them with empty-name `json:",omitempty"` tags), so this
857    /// fixture uses control's real PascalCase keys.
858    #[test]
859    fn from_serde_carries_recorders_and_on_recording_failure() {
860        let wire = r#"{
861            "rules": [
862                {
863                    "principals": [{ "any": true }],
864                    "sshUsers": { "*": "=" },
865                    "action": {
866                        "accept": true,
867                        "recorders": ["1.2.3.4:5678", "5.6.7.8:9000"],
868                        "onRecordingFailure": {
869                            "RejectSessionWithMessage": "recording required by policy",
870                            "NotifyURL": "https://example.com/notify"
871                        }
872                    }
873                }
874            ]
875        }"#;
876        let serde_pol: ts_control_serde::SSHPolicy = serde_json::from_str(wire).unwrap();
877        let pol = SshPolicy::from_serde(&serde_pol);
878
879        // The domain action retained the recording fields.
880        let action = pol.rules[0].action.as_ref().unwrap();
881        assert_eq!(
882            action.recorders,
883            vec![
884                "1.2.3.4:5678".parse::<SocketAddr>().unwrap(),
885                "5.6.7.8:9000".parse::<SocketAddr>().unwrap(),
886            ]
887        );
888        let orf = action.on_recording_failure.as_ref().unwrap();
889        assert_eq!(
890            orf.reject_session_with_message,
891            "recording required by policy"
892        );
893        assert_eq!(orf.notify_url, "https://example.com/notify");
894
895        // And evaluation surfaces both to the server: the recorders it must stream to and the
896        // explicit fail-closed message to use if it cannot.
897        match pol.evaluate(&id("n1", "100.64.0.1", None), "root", now()) {
898            SshDecision::Accept(a) => {
899                assert_eq!(a.recorders.len(), 2);
900                assert_eq!(
901                    a.on_recording_failure
902                        .as_ref()
903                        .map(|f| f.reject_session_with_message.as_str()),
904                    Some("recording required by policy"),
905                );
906                assert_eq!(a.recording_refusal_message, "recording required by policy");
907            }
908            other => panic!("expected accept, got {other:?}"),
909        }
910    }
911
912    /// Refusal-message precedence: with no `RejectSessionWithMessage`, fall back to the action
913    /// `message`.
914    #[test]
915    fn recording_refusal_message_falls_back_to_action_message() {
916        let action = SshAction {
917            accept: true,
918            message: "see your admin".to_string(),
919            recorders: vec!["1.2.3.4:5678".parse().unwrap()],
920            ..Default::default()
921        };
922        assert_eq!(action.recording_refusal_message(), "see your admin");
923
924        // An empty RejectSessionWithMessage must NOT mask the action message fallback.
925        let action = SshAction {
926            on_recording_failure: Some(SshRecorderFailureAction::default()),
927            ..action
928        };
929        assert_eq!(action.recording_refusal_message(), "see your admin");
930    }
931}