use std::net::SocketAddr;
use std::sync::Arc;
use fidius_host::http_types::request::Parts as HttpParts;
use fidius_host::{EgressDenied, EgressPolicy};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GrantSpec {
pub http: Vec<String>,
pub tcp: Vec<String>,
pub fs: Vec<String>,
pub env: Vec<String>,
pub secrets: Vec<String>,
}
impl GrantSpec {
pub fn from_lists(
http: Vec<String>,
tcp: Vec<String>,
fs: Vec<String>,
env: Vec<String>,
secrets: Vec<String>,
) -> Self {
Self {
http,
tcp,
fs,
env,
secrets,
}
}
pub fn from_pairs(pairs: Vec<(String, Vec<String>)>) -> Self {
let mut spec = Self::default();
for (kind, patterns) in pairs {
match kind.as_str() {
"http" => spec.http.extend(patterns),
"tcp" => spec.tcp.extend(patterns),
"fs" => spec.fs.extend(patterns),
"env" => spec.env.extend(patterns),
"secrets" => spec.secrets.extend(patterns),
_ => {}
}
}
spec
}
pub fn is_empty(&self) -> bool {
self.http.is_empty()
&& self.tcp.is_empty()
&& self.fs.is_empty()
&& self.env.is_empty()
&& self.secrets.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrantError(pub String);
impl std::fmt::Display for GrantError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "invalid constructor grant: {}", self.0)
}
}
impl std::error::Error for GrantError {}
#[derive(Clone)]
pub struct ResolvedGrants {
pub capabilities: Vec<String>,
pub egress: Option<Arc<dyn EgressPolicy>>,
pub secrets: Vec<String>,
}
impl ResolvedGrants {
pub fn deny_all() -> Self {
Self {
capabilities: Vec::new(),
egress: None,
secrets: Vec::new(),
}
}
}
impl Default for ResolvedGrants {
fn default() -> Self {
Self::deny_all()
}
}
pub fn translate(spec: &GrantSpec) -> Result<ResolvedGrants, GrantError> {
let mut capabilities: Vec<String> = Vec::new();
for entry in &spec.fs {
let cap = if let Some(path) = entry.strip_prefix("ro:") {
non_empty(path, "fs", "ro:<path>")?;
format!("fs:ro:{path}")
} else if let Some(path) = entry.strip_prefix("rw:") {
non_empty(path, "fs", "rw:<path>")?;
format!("fs:rw:{path}")
} else {
return Err(GrantError(format!(
"fs grant '{entry}' must start with 'ro:' or 'rw:' (e.g. 'ro:/data')"
)));
};
capabilities.push(cap);
}
for name in &spec.env {
if name.is_empty() {
return Err(GrantError("env grant has an empty variable name".into()));
}
if name.contains('=') {
return Err(GrantError(format!(
"env grant '{name}' must be a bare variable NAME (host passthrough); \
literal `KEY=value` injection is not supported yet"
)));
}
capabilities.push(format!("env:{name}"));
}
let http = compile_http(&spec.http)?;
let tcp = compile_tcp(&spec.tcp)?;
if http.is_some() {
capabilities.push("http".into());
}
if tcp.is_some() {
capabilities.push("tcp".into());
}
let egress: Option<Arc<dyn EgressPolicy>> = if http.is_some() || tcp.is_some() {
Some(Arc::new(GrantEgressPolicy {
http: http.unwrap_or_default(),
tcp: tcp.unwrap_or_default(),
}))
} else {
None
};
for name in &spec.secrets {
if name.is_empty() {
return Err(GrantError("secrets grant has an empty secret name".into()));
}
}
Ok(ResolvedGrants {
capabilities,
egress,
secrets: spec.secrets.clone(),
})
}
fn non_empty(s: &str, kind: &str, example: &str) -> Result<(), GrantError> {
if s.is_empty() {
Err(GrantError(format!(
"{kind} grant '{example}' requires a value"
)))
} else {
Ok(())
}
}
#[derive(Debug, Clone)]
struct HttpPattern {
host: String,
port: Option<u16>,
path: Option<String>,
}
fn compile_http(patterns: &[String]) -> Result<Option<Vec<HttpPattern>>, GrantError> {
if patterns.is_empty() {
return Ok(None);
}
let mut out = Vec::with_capacity(patterns.len());
for raw in patterns {
let rest = raw.split_once("://").map(|(_, r)| r).unwrap_or(raw);
let (authority, path) = match rest.split_once('/') {
Some((a, p)) => (a, Some(format!("/{p}"))),
None => (rest, None),
};
let (host, port) = match authority.rsplit_once(':') {
Some((h, p)) if !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()) => {
let port = p
.parse::<u16>()
.map_err(|_| GrantError(format!("http grant '{raw}' has an invalid port")))?;
(h.to_string(), Some(port))
}
_ => (authority.to_string(), None),
};
if host.is_empty() {
return Err(GrantError(format!("http grant '{raw}' has an empty host")));
}
out.push(HttpPattern { host, port, path });
}
Ok(Some(out))
}
#[derive(Debug, Clone, Default)]
struct TcpRules {
any: bool,
ports: Vec<u16>,
addrs: Vec<SocketAddr>,
}
fn compile_tcp(patterns: &[String]) -> Result<Option<TcpRules>, GrantError> {
if patterns.is_empty() {
return Ok(None);
}
let mut rules = TcpRules::default();
for raw in patterns {
if raw == "*" {
rules.any = true;
continue;
}
let (host, port_s) = raw.rsplit_once(':').ok_or_else(|| {
GrantError(format!(
"tcp grant '{raw}' must be 'host:port' (or '*:port' / '*')"
))
})?;
let port = port_s
.parse::<u16>()
.map_err(|_| GrantError(format!("tcp grant '{raw}' has an invalid port '{port_s}'")))?;
if host == "*" {
rules.ports.push(port);
continue;
}
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
rules.addrs.push(SocketAddr::new(ip, port));
} else {
match std::net::ToSocketAddrs::to_socket_addrs(&(host, port)) {
Ok(addrs) => rules.addrs.extend(addrs),
Err(_e) => {
tracing::warn!(
grant = %raw,
"tcp grant host did not resolve at load; it authorizes no peer (fail-closed)"
);
}
}
}
}
Ok(Some(rules))
}
struct GrantEgressPolicy {
http: Vec<HttpPattern>,
tcp: TcpRules,
}
impl std::fmt::Debug for GrantEgressPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GrantEgressPolicy")
.field("http_patterns", &self.http.len())
.field("tcp", &self.tcp)
.finish()
}
}
impl EgressPolicy for GrantEgressPolicy {
fn authorize(&self, parts: &mut HttpParts) -> Result<(), EgressDenied> {
let uri = &parts.uri;
let host = uri.host().unwrap_or("");
let port = uri.port_u16();
let path = uri.path();
for p in &self.http {
let host_ok = p.host == "*" || glob_match(&p.host, host);
let port_ok = match p.port {
Some(want) => Some(want) == port,
None => true,
};
let path_ok = match &p.path {
Some(pat) => glob_match(pat, path),
None => true,
};
if host_ok && port_ok && path_ok {
return Ok(());
}
}
Err(EgressDenied::new(format!(
"http egress to {host}{} {path} not in the constructor's grants",
port.map(|p| format!(":{p}")).unwrap_or_default()
)))
}
fn authorize_tcp(&self, addr: &SocketAddr) -> Result<(), EgressDenied> {
if self.tcp.any || self.tcp.ports.contains(&addr.port()) || self.tcp.addrs.contains(addr) {
return Ok(());
}
Err(EgressDenied::new(format!(
"tcp egress to {addr} not in the constructor's grants"
)))
}
}
fn glob_match(pattern: &str, text: &str) -> bool {
let (p, t) = (pattern.as_bytes(), text.as_bytes());
let (mut pi, mut ti) = (0usize, 0usize);
let (mut star, mut mark) = (None, 0usize);
while ti < t.len() {
if pi < p.len() && (p[pi] == b'*') {
star = Some(pi);
mark = ti;
pi += 1;
} else if pi < p.len() && p[pi] == t[ti] {
pi += 1;
ti += 1;
} else if let Some(s) = star {
pi = s + 1;
mark += 1;
ti = mark;
} else {
return false;
}
}
while pi < p.len() && p[pi] == b'*' {
pi += 1;
}
pi == p.len()
}
pub fn lint_unmet_intents(manifest_caps: &[String], spec: &GrantSpec) -> Vec<String> {
let mut warnings = Vec::new();
for cap in manifest_caps {
let unmet = match cap.as_str() {
"http" => spec.http.is_empty(),
"tcp" | "udp" | "network" | "sockets" => spec.tcp.is_empty(),
_ if cap.starts_with("fs:") => spec.fs.is_empty(),
_ if cap.starts_with("env:") => spec.env.is_empty(),
_ => false,
};
if unmet {
warnings.push(format!(
"constructor declares capability '{cap}' but the workflow granted no \
matching access; it will be denied at runtime (add it to `grants`)"
));
}
}
warnings
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
fn parts(uri: &str) -> HttpParts {
let req = fidius_host::http_types::Request::builder()
.uri(uri)
.body(())
.unwrap();
req.into_parts().0
}
#[test]
fn empty_spec_is_deny_all() {
let r = translate(&GrantSpec::default()).unwrap();
assert!(r.capabilities.is_empty());
assert!(r.egress.is_none(), "no grant ⇒ no policy ⇒ fidius deny-all");
}
#[test]
fn fs_grants_map_to_scoped_caps() {
let spec = GrantSpec::from_lists(
vec![],
vec![],
vec!["ro:/data".into(), "rw:/out".into()],
vec![],
vec![],
);
let r = translate(&spec).unwrap();
assert_eq!(r.capabilities, vec!["fs:ro:/data", "fs:rw:/out"]);
assert!(r.egress.is_none());
}
#[test]
fn fs_without_mode_prefix_fails_closed() {
let spec = GrantSpec::from_lists(vec![], vec![], vec!["/data".into()], vec![], vec![]);
assert!(translate(&spec).is_err());
}
#[test]
fn env_grants_map_to_scoped_caps_and_reject_literals() {
let ok = GrantSpec::from_lists(vec![], vec![], vec![], vec!["STRIPE_KEY".into()], vec![]);
assert_eq!(translate(&ok).unwrap().capabilities, vec!["env:STRIPE_KEY"]);
let literal = GrantSpec::from_lists(vec![], vec![], vec![], vec!["K=v".into()], vec![]);
assert!(translate(&literal).is_err());
let empty = GrantSpec::from_lists(vec![], vec![], vec![], vec!["".into()], vec![]);
assert!(translate(&empty).is_err());
}
#[test]
fn http_intent_marker_and_policy_present() {
let spec = GrantSpec::from_lists(
vec!["api.example.com:443".into()],
vec![],
vec![],
vec![],
vec![],
);
let r = translate(&spec).unwrap();
assert_eq!(r.capabilities, vec!["http"]);
assert!(r.egress.is_some());
}
#[test]
fn http_policy_matches_host_port_and_path() {
let spec = GrantSpec::from_lists(
vec!["https://api.example.com/v1/*".into()],
vec![],
vec![],
vec![],
vec![],
);
let policy = translate(&spec).unwrap().egress.unwrap();
assert!(policy
.authorize(&mut parts("https://api.example.com/v1/charge"))
.is_ok());
assert!(policy
.authorize(&mut parts("https://api.example.com/v2/admin"))
.is_err());
assert!(policy
.authorize(&mut parts("https://evil.example.com/v1/charge"))
.is_err());
}
#[test]
fn http_host_only_grant_allows_any_path() {
let spec = GrantSpec::from_lists(
vec!["api.example.com".into()],
vec![],
vec![],
vec![],
vec![],
);
let policy = translate(&spec).unwrap().egress.unwrap();
assert!(policy
.authorize(&mut parts("https://api.example.com/anything"))
.is_ok());
assert!(policy
.authorize(&mut parts("https://other.example.com/anything"))
.is_err());
}
#[test]
fn http_star_allows_all() {
let spec = GrantSpec::from_lists(vec!["*".into()], vec![], vec![], vec![], vec![]);
let policy = translate(&spec).unwrap().egress.unwrap();
assert!(policy
.authorize(&mut parts("https://anywhere.test/x"))
.is_ok());
}
#[test]
fn tcp_star_port_matches_any_host_on_that_port() {
let spec = GrantSpec::from_lists(vec![], vec!["*:5432".into()], vec![], vec![], vec![]);
let r = translate(&spec).unwrap();
assert_eq!(r.capabilities, vec!["tcp"]);
let policy = r.egress.unwrap();
let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 9));
assert!(policy.authorize_tcp(&SocketAddr::new(ip, 5432)).is_ok());
assert!(policy.authorize_tcp(&SocketAddr::new(ip, 5433)).is_err());
}
#[test]
fn tcp_literal_ip_port_exact_match() {
let spec =
GrantSpec::from_lists(vec![], vec!["10.0.0.5:5432".into()], vec![], vec![], vec![]);
let policy = translate(&spec).unwrap().egress.unwrap();
let ok = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), 5432);
let wrong_ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 6)), 5432);
let wrong_port = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), 1);
assert!(policy.authorize_tcp(&ok).is_ok());
assert!(policy.authorize_tcp(&wrong_ip).is_err());
assert!(policy.authorize_tcp(&wrong_port).is_err());
}
#[test]
fn tcp_without_port_fails_closed() {
let spec =
GrantSpec::from_lists(vec![], vec!["db.internal".into()], vec![], vec![], vec![]);
assert!(translate(&spec).is_err());
}
#[test]
fn combined_grants_accumulate_caps() {
let spec = GrantSpec::from_lists(
vec!["api.example.com:443".into()],
vec!["*:5432".into()],
vec!["ro:/data".into()],
vec!["TOKEN".into()],
vec![],
);
let r = translate(&spec).unwrap();
assert!(r.capabilities.contains(&"http".to_string()));
assert!(r.capabilities.contains(&"tcp".to_string()));
assert!(r.capabilities.contains(&"fs:ro:/data".to_string()));
assert!(r.capabilities.contains(&"env:TOKEN".to_string()));
assert!(r.egress.is_some());
}
#[test]
fn secrets_flow_into_resolved_grants() {
let spec = GrantSpec::from_lists(
vec![],
vec![],
vec![],
vec![],
vec!["db_prod".into(), "stripe".into()],
);
let r = translate(&spec).unwrap();
assert_eq!(r.secrets, vec!["db_prod", "stripe"]);
assert!(r.capabilities.is_empty());
assert!(r.egress.is_none());
}
#[test]
fn empty_secrets_grant_is_empty() {
let r = translate(&GrantSpec::default()).unwrap();
assert!(r.secrets.is_empty());
}
#[test]
fn secrets_lower_from_raw_pairs() {
let spec = GrantSpec::from_pairs(vec![(
"secrets".to_string(),
vec!["db_prod".to_string(), "cache".to_string()],
)]);
assert_eq!(spec.secrets, vec!["db_prod", "cache"]);
assert!(!spec.is_empty());
assert_eq!(translate(&spec).unwrap().secrets, vec!["db_prod", "cache"]);
}
#[test]
fn empty_secret_name_fails_closed() {
let spec = GrantSpec::from_lists(vec![], vec![], vec![], vec![], vec!["".into()]);
assert!(translate(&spec).is_err());
}
#[test]
fn lint_flags_unmet_intent_only() {
let manifest = vec!["http".to_string(), "env:TOKEN".to_string()];
let spec = GrantSpec::from_lists(vec!["*".into()], vec![], vec![], vec![], vec![]);
let w = lint_unmet_intents(&manifest, &spec);
assert_eq!(w.len(), 1);
assert!(w[0].contains("env:TOKEN"));
let spec2 = GrantSpec::from_lists(
vec!["*".into()],
vec![],
vec![],
vec!["TOKEN".into()],
vec![],
);
assert!(lint_unmet_intents(&manifest, &spec2).is_empty());
}
#[test]
fn glob_match_basics() {
assert!(glob_match("*", "anything"));
assert!(glob_match("/v1/*", "/v1/charge"));
assert!(glob_match("/v1/*", "/v1/"));
assert!(!glob_match("/v1/*", "/v2/charge"));
assert!(glob_match("a*c", "abc"));
assert!(glob_match("a*c", "ac"));
assert!(!glob_match("a*c", "ab"));
assert!(glob_match("*.example.com", "api.example.com"));
assert!(!glob_match("*.example.com", "example.com"));
}
}