use alloc::{
collections::BTreeMap,
string::{String, ToString},
vec::Vec,
};
use core::net::{IpAddr, SocketAddr};
use chrono::{DateTime, Utc};
const WILDCARD_USER: &str = "*";
const IDENTITY_MAP: &str = "=";
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct SshPolicy {
pub rules: Vec<SshRule>,
}
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct SshRule {
pub rule_expires: Option<DateTime<Utc>>,
pub principals: Vec<SshPrincipal>,
pub ssh_users: BTreeMap<String, String>,
pub action: Option<SshAction>,
pub accept_env: Vec<String>,
}
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct SshPrincipal {
pub node: String,
pub node_ip: String,
pub user_login: String,
pub any: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct SshAction {
pub message: String,
pub reject: bool,
pub accept: bool,
pub session_duration_nanos: Option<i64>,
pub allow_agent_forwarding: bool,
pub allow_local_port_forwarding: bool,
pub allow_remote_port_forwarding: bool,
pub recorders: Vec<SocketAddr>,
pub on_recording_failure: Option<SshRecorderFailureAction>,
pub hold_and_delegate: String,
}
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct SshRecorderFailureAction {
pub reject_session_with_message: String,
pub terminate_session_with_message: String,
pub notify_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshConnIdentity {
pub stable_id: String,
pub src_ip: IpAddr,
pub user_login: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SshDecision {
Accept(SshAccept),
Deny(SshDenyReason),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshAccept {
pub local_user: String,
pub accept_env: Vec<String>,
pub session_duration_nanos: Option<i64>,
pub allow_agent_forwarding: bool,
pub allow_local_port_forwarding: bool,
pub allow_remote_port_forwarding: bool,
pub recorders: Vec<SocketAddr>,
pub recording_required: bool,
pub recording_refusal_message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SshDenyReason {
ExplicitReject {
message: String,
},
NoRuleMatched,
NoUserMapping,
}
enum RuleSkip {
NoMatch,
UserMatch,
}
impl SshPolicy {
pub fn from_serde(p: &ts_control_serde::SSHPolicy<'_>) -> Self {
SshPolicy {
rules: p.rules.iter().map(SshRule::from_serde).collect(),
}
}
pub fn evaluate_at_unix(
&self,
id: &SshConnIdentity,
requested_user: &str,
now_unix_secs: i64,
) -> SshDecision {
let now = DateTime::from_timestamp(now_unix_secs, 0).unwrap_or(DateTime::<Utc>::MAX_UTC);
self.evaluate(id, requested_user, now)
}
pub fn evaluate(
&self,
id: &SshConnIdentity,
requested_user: &str,
now: DateTime<Utc>,
) -> SshDecision {
let mut failed_on_user = false;
for rule in &self.rules {
match rule.try_match(id, requested_user, now) {
Ok(decision) => return decision,
Err(RuleSkip::UserMatch) => failed_on_user = true,
Err(RuleSkip::NoMatch) => {}
}
}
SshDecision::Deny(if failed_on_user {
SshDenyReason::NoUserMapping
} else {
SshDenyReason::NoRuleMatched
})
}
}
impl SshRule {
fn from_serde(r: &ts_control_serde::SSHRule<'_>) -> Self {
SshRule {
rule_expires: r.rule_expires,
principals: r.principals.iter().map(SshPrincipal::from_serde).collect(),
ssh_users: r
.ssh_users
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
action: r.action.as_ref().map(SshAction::from_serde),
accept_env: r.accept_env.iter().map(|s| s.to_string()).collect(),
}
}
fn try_match(
&self,
id: &SshConnIdentity,
requested_user: &str,
now: DateTime<Utc>,
) -> Result<SshDecision, RuleSkip> {
let action = self.action.as_ref().ok_or(RuleSkip::NoMatch)?;
if self.is_expired(now) {
return Err(RuleSkip::NoMatch);
}
if !self.principals.iter().any(|p| p.matches(id)) {
return Err(RuleSkip::NoMatch);
}
if action.reject {
return Ok(SshDecision::Deny(SshDenyReason::ExplicitReject {
message: action.message.clone(),
}));
}
let local_user =
map_local_user(&self.ssh_users, requested_user).ok_or(RuleSkip::UserMatch)?;
let recording_required =
!action.recorders.is_empty() || !action.hold_and_delegate.is_empty();
let recording_refusal_message = action.recording_refusal_message();
Ok(SshDecision::Accept(SshAccept {
local_user,
accept_env: self.accept_env.clone(),
session_duration_nanos: action.session_duration_nanos,
allow_agent_forwarding: action.allow_agent_forwarding,
allow_local_port_forwarding: action.allow_local_port_forwarding,
allow_remote_port_forwarding: action.allow_remote_port_forwarding,
recorders: action.recorders.clone(),
recording_required,
recording_refusal_message,
}))
}
fn is_expired(&self, now: DateTime<Utc>) -> bool {
match self.rule_expires {
None => false,
Some(expiry) => expiry < now,
}
}
}
impl SshPrincipal {
fn from_serde(p: &ts_control_serde::SSHPrincipal<'_>) -> Self {
SshPrincipal {
node: p.node.0.to_string(),
node_ip: p.node_ip.to_string(),
user_login: p.user_login.to_string(),
any: p.any,
}
}
fn matches(&self, id: &SshConnIdentity) -> bool {
if self.any {
return true;
}
if !self.node.is_empty() && self.node == id.stable_id {
return true;
}
if !self.node_ip.is_empty()
&& self
.node_ip
.parse::<IpAddr>()
.is_ok_and(|ip| ip == id.src_ip)
{
return true;
}
if !self.user_login.is_empty()
&& id
.user_login
.as_deref()
.is_some_and(|login| login == self.user_login)
{
return true;
}
false
}
}
impl SshAction {
fn from_serde(a: &ts_control_serde::SSHAction<'_>) -> Self {
SshAction {
message: a.message.to_string(),
reject: a.reject,
accept: a.accept,
session_duration_nanos: a.session_duration.filter(|d| *d != 0),
allow_agent_forwarding: a.allow_agent_forwarding,
allow_local_port_forwarding: a.allow_local_port_forwarding,
allow_remote_port_forwarding: a.allow_remote_port_forwarding,
recorders: a.recorders.clone(),
on_recording_failure: a
.on_recording_failure
.as_ref()
.map(SshRecorderFailureAction::from_serde),
hold_and_delegate: a.hold_and_delegate.to_string(),
}
}
fn recording_refusal_message(&self) -> String {
if let Some(orf) = &self.on_recording_failure
&& !orf.reject_session_with_message.is_empty()
{
return orf.reject_session_with_message.clone();
}
self.message.clone()
}
}
impl SshRecorderFailureAction {
fn from_serde(f: &ts_control_serde::SSHRecorderFailureAction<'_>) -> Self {
SshRecorderFailureAction {
reject_session_with_message: f.reject_session_with_message.to_string(),
terminate_session_with_message: f.terminate_session_with_message.to_string(),
notify_url: f.notify_url.to_string(),
}
}
}
fn map_local_user(ssh_users: &BTreeMap<String, String>, requested_user: &str) -> Option<String> {
let mapped = ssh_users
.get(requested_user)
.or_else(|| ssh_users.get(WILDCARD_USER))?;
if mapped.is_empty() {
return None;
}
if mapped == IDENTITY_MAP {
return Some(requested_user.to_string());
}
Some(mapped.clone())
}
#[cfg(test)]
mod tests {
use alloc::vec;
use super::*;
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
fn now() -> DateTime<Utc> {
"2026-06-05T00:00:00Z".parse().unwrap()
}
fn id(stable_id: &str, src: &str, login: Option<&str>) -> SshConnIdentity {
SshConnIdentity {
stable_id: stable_id.to_string(),
src_ip: ip(src),
user_login: login.map(|s| s.to_string()),
}
}
fn accept_rule(principals: Vec<SshPrincipal>, ssh_users: &[(&str, &str)]) -> SshRule {
SshRule {
rule_expires: None,
principals,
ssh_users: ssh_users
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
action: Some(SshAction {
accept: true,
..Default::default()
}),
accept_env: vec![],
}
}
fn any_principal() -> SshPrincipal {
SshPrincipal {
any: true,
..Default::default()
}
}
#[test]
fn empty_policy_denies() {
let pol = SshPolicy::default();
let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
assert_eq!(d, SshDecision::Deny(SshDenyReason::NoRuleMatched));
}
#[test]
fn any_principal_with_wildcard_user_accepts_identity_map() {
let pol = SshPolicy {
rules: vec![accept_rule(vec![any_principal()], &[("*", "=")])],
};
let d = pol.evaluate(&id("n1", "100.64.0.1", None), "ubuntu", now());
match d {
SshDecision::Accept(a) => assert_eq!(a.local_user, "ubuntu"),
other => panic!("expected accept, got {other:?}"),
}
}
#[test]
fn wildcard_user_with_fixed_local_user() {
let pol = SshPolicy {
rules: vec![accept_rule(vec![any_principal()], &[("*", "deploy")])],
};
let d = pol.evaluate(&id("n1", "100.64.0.1", None), "anything", now());
match d {
SshDecision::Accept(a) => assert_eq!(a.local_user, "deploy"),
other => panic!("expected accept, got {other:?}"),
}
}
#[test]
fn empty_string_user_value_denies_as_no_user_mapping() {
let pol = SshPolicy {
rules: vec![accept_rule(vec![any_principal()], &[("root", "")])],
};
let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
assert_eq!(d, SshDecision::Deny(SshDenyReason::NoUserMapping));
}
#[test]
fn no_matching_user_key_falls_through_to_no_user_mapping() {
let pol = SshPolicy {
rules: vec![accept_rule(vec![any_principal()], &[("alice", "alice")])],
};
let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
assert_eq!(d, SshDecision::Deny(SshDenyReason::NoUserMapping));
}
#[test]
fn specific_user_key_preferred_over_wildcard() {
let pol = SshPolicy {
rules: vec![accept_rule(
vec![any_principal()],
&[("root", "rootlocal"), ("*", "nobody")],
)],
};
let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
match d {
SshDecision::Accept(a) => assert_eq!(a.local_user, "rootlocal"),
other => panic!("expected accept, got {other:?}"),
}
}
#[test]
fn principal_matches_by_stable_id() {
let pol = SshPolicy {
rules: vec![accept_rule(
vec![SshPrincipal {
node: "nABC".to_string(),
..Default::default()
}],
&[("*", "=")],
)],
};
let yes = pol.evaluate(&id("nABC", "100.64.0.9", None), "u", now());
assert!(matches!(yes, SshDecision::Accept(_)));
let no = pol.evaluate(&id("nXYZ", "100.64.0.9", None), "u", now());
assert_eq!(no, SshDecision::Deny(SshDenyReason::NoRuleMatched));
}
#[test]
fn principal_matches_by_node_ip() {
let pol = SshPolicy {
rules: vec![accept_rule(
vec![SshPrincipal {
node_ip: "100.64.0.7".to_string(),
..Default::default()
}],
&[("*", "=")],
)],
};
let yes = pol.evaluate(&id("n1", "100.64.0.7", None), "u", now());
assert!(matches!(yes, SshDecision::Accept(_)));
let no = pol.evaluate(&id("n1", "100.64.0.8", None), "u", now());
assert_eq!(no, SshDecision::Deny(SshDenyReason::NoRuleMatched));
}
#[test]
fn principal_matches_by_user_login() {
let pol = SshPolicy {
rules: vec![accept_rule(
vec![SshPrincipal {
user_login: "alice@example.com".to_string(),
..Default::default()
}],
&[("*", "=")],
)],
};
let yes = pol.evaluate(
&id("n1", "100.64.0.1", Some("alice@example.com")),
"u",
now(),
);
assert!(matches!(yes, SshDecision::Accept(_)));
let no = pol.evaluate(&id("n1", "100.64.0.1", None), "u", now());
assert_eq!(no, SshDecision::Deny(SshDenyReason::NoRuleMatched));
}
#[test]
fn all_empty_non_any_principal_matches_nothing() {
let pol = SshPolicy {
rules: vec![accept_rule(vec![SshPrincipal::default()], &[("*", "=")])],
};
let d = pol.evaluate(&id("n1", "100.64.0.1", Some("a@b")), "u", now());
assert_eq!(d, SshDecision::Deny(SshDenyReason::NoRuleMatched));
}
#[test]
fn explicit_reject_short_circuits_before_user_mapping() {
let pol = SshPolicy {
rules: vec![SshRule {
principals: vec![any_principal()],
action: Some(SshAction {
reject: true,
message: "go away".to_string(),
..Default::default()
}),
..Default::default()
}],
};
let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
assert_eq!(
d,
SshDecision::Deny(SshDenyReason::ExplicitReject {
message: "go away".to_string()
})
);
}
#[test]
fn first_matching_rule_wins() {
let pol = SshPolicy {
rules: vec![
SshRule {
principals: vec![any_principal()],
action: Some(SshAction {
reject: true,
..Default::default()
}),
..Default::default()
},
accept_rule(vec![any_principal()], &[("*", "=")]),
],
};
let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
assert!(matches!(
d,
SshDecision::Deny(SshDenyReason::ExplicitReject { .. })
));
}
#[test]
fn rule_with_no_action_is_skipped() {
let pol = SshPolicy {
rules: vec![
SshRule {
principals: vec![any_principal()],
action: None,
..Default::default()
},
accept_rule(vec![any_principal()], &[("*", "=")]),
],
};
let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
assert!(matches!(d, SshDecision::Accept(_)));
}
#[test]
fn expired_rule_is_skipped() {
let past = "2000-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
let pol = SshPolicy {
rules: vec![SshRule {
rule_expires: Some(past),
..accept_rule(vec![any_principal()], &[("*", "=")])
}],
};
let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
assert_eq!(d, SshDecision::Deny(SshDenyReason::NoRuleMatched));
}
#[test]
fn unexpired_rule_still_matches() {
let future = "2999-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
let pol = SshPolicy {
rules: vec![SshRule {
rule_expires: Some(future),
..accept_rule(vec![any_principal()], &[("*", "=")])
}],
};
let d = pol.evaluate(&id("n1", "100.64.0.1", None), "root", now());
assert!(matches!(d, SshDecision::Accept(_)));
}
#[test]
fn evaluate_at_unix_far_future_expires_time_limited_rules() {
let future = "2999-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
let pol = SshPolicy {
rules: vec![SshRule {
rule_expires: Some(future),
..accept_rule(vec![any_principal()], &[("*", "=")])
}],
};
let d = pol.evaluate_at_unix(&id("n1", "100.64.0.1", None), "root", i64::MAX);
assert_eq!(d, SshDecision::Deny(SshDenyReason::NoRuleMatched));
}
#[test]
fn session_duration_zero_is_unlimited() {
let serde_action = ts_control_serde::SSHAction {
accept: true,
session_duration: Some(0),
..Default::default()
};
assert_eq!(
SshAction::from_serde(&serde_action).session_duration_nanos,
None
);
}
#[test]
fn from_serde_round_trips_a_policy() {
let wire = r#"{
"rules": [
{
"principals": [{ "any": true }],
"sshUsers": { "*": "=" },
"action": { "accept": true, "allowAgentForwarding": true }
}
]
}"#;
let serde_pol: ts_control_serde::SSHPolicy = serde_json::from_str(wire).unwrap();
let pol = SshPolicy::from_serde(&serde_pol);
let d = pol.evaluate(&id("n1", "100.64.0.1", None), "ubuntu", now());
match d {
SshDecision::Accept(a) => {
assert_eq!(a.local_user, "ubuntu");
assert!(a.allow_agent_forwarding);
}
other => panic!("expected accept, got {other:?}"),
}
}
#[test]
fn recorders_set_marks_accept_recording_required() {
let recorder: SocketAddr = "1.2.3.4:5678".parse().unwrap();
let pol = SshPolicy {
rules: vec![SshRule {
action: Some(SshAction {
accept: true,
recorders: vec![recorder],
..Default::default()
}),
..accept_rule(vec![any_principal()], &[("*", "=")])
}],
};
match pol.evaluate(&id("n1", "100.64.0.1", None), "root", now()) {
SshDecision::Accept(a) => {
assert!(a.recording_required, "recorders set must demand recording");
assert_eq!(a.recorders, vec![recorder]);
}
other => panic!("expected accept, got {other:?}"),
}
}
#[test]
fn no_recorders_accept_is_not_recording_required() {
let pol = SshPolicy {
rules: vec![accept_rule(vec![any_principal()], &[("*", "=")])],
};
match pol.evaluate(&id("n1", "100.64.0.1", None), "root", now()) {
SshDecision::Accept(a) => {
assert!(
!a.recording_required,
"no recorders must not demand recording"
);
assert!(a.recorders.is_empty());
assert!(a.recording_refusal_message.is_empty());
}
other => panic!("expected accept, got {other:?}"),
}
}
#[test]
fn hold_and_delegate_marks_accept_recording_required() {
let pol = SshPolicy {
rules: vec![SshRule {
action: Some(SshAction {
accept: true,
hold_and_delegate: "https://control.example/ssh/action/xyz".to_string(),
..Default::default()
}),
..accept_rule(vec![any_principal()], &[("*", "=")])
}],
};
match pol.evaluate(&id("n1", "100.64.0.1", None), "root", now()) {
SshDecision::Accept(a) => {
assert!(
a.recording_required,
"holdAndDelegate must be enforced fail-closed (not silently accepted)"
);
}
other => panic!("expected accept, got {other:?}"),
}
}
#[test]
fn from_serde_carries_recorders_and_on_recording_failure() {
let wire = r#"{
"rules": [
{
"principals": [{ "any": true }],
"sshUsers": { "*": "=" },
"action": {
"accept": true,
"recorders": ["1.2.3.4:5678", "5.6.7.8:9000"],
"onRecordingFailure": {
"rejectSessionWithMessage": "recording required by policy",
"notifyURL": "https://example.com/notify"
}
}
}
]
}"#;
let serde_pol: ts_control_serde::SSHPolicy = serde_json::from_str(wire).unwrap();
let pol = SshPolicy::from_serde(&serde_pol);
let action = pol.rules[0].action.as_ref().unwrap();
assert_eq!(
action.recorders,
vec![
"1.2.3.4:5678".parse::<SocketAddr>().unwrap(),
"5.6.7.8:9000".parse::<SocketAddr>().unwrap(),
]
);
let orf = action.on_recording_failure.as_ref().unwrap();
assert_eq!(
orf.reject_session_with_message,
"recording required by policy"
);
assert_eq!(orf.notify_url, "https://example.com/notify");
match pol.evaluate(&id("n1", "100.64.0.1", None), "root", now()) {
SshDecision::Accept(a) => {
assert!(a.recording_required);
assert_eq!(a.recording_refusal_message, "recording required by policy");
}
other => panic!("expected accept, got {other:?}"),
}
}
#[test]
fn recording_refusal_message_falls_back_to_action_message() {
let action = SshAction {
accept: true,
message: "see your admin".to_string(),
recorders: vec!["1.2.3.4:5678".parse().unwrap()],
..Default::default()
};
assert_eq!(action.recording_refusal_message(), "see your admin");
let action = SshAction {
on_recording_failure: Some(SshRecorderFailureAction::default()),
..action
};
assert_eq!(action.recording_refusal_message(), "see your admin");
}
}