1pub mod apply;
9mod glob;
10mod redact;
11mod schema;
12
13use std::collections::HashSet;
14use std::sync::Arc;
15
16use mlua::Lua;
17
18pub use redact::{is_redacted_header, redact_json_text};
19pub use schema::Classify;
20use schema::{HttpRule, PolicyFile};
21
22pub const POLICY_FILE_ENV: &str = "ASSAY_POLICY_FILE";
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Classification {
28 Read,
29 Write,
30}
31
32#[derive(Debug, Default)]
33pub struct Policy {
34 module_allow: Option<HashSet<String>>,
35 env_allow: Option<HashSet<String>>,
36 http_rules: Option<Vec<HttpRule>>,
37 max_response_bytes: Option<usize>,
38 redact: Vec<String>,
39}
40
41#[derive(Clone)]
42pub struct PolicyHandle(pub Arc<Policy>);
43
44pub fn active(lua: &Lua) -> Option<Arc<Policy>> {
46 lua.app_data_ref::<PolicyHandle>()
47 .map(|handle| Arc::clone(&handle.0))
48}
49
50pub fn install(lua: &Lua, policy: Arc<Policy>) {
51 lua.set_app_data(PolicyHandle(policy));
52}
53
54pub fn env_visible(lua: &Lua, key: &str) -> bool {
55 active(lua).is_none_or(|p| p.env_visible(key))
56}
57
58pub fn guard_require(lua: &Lua, module: &str) -> mlua::Result<()> {
59 match active(lua) {
60 Some(p) if !p.module_allowed(module) => Err(mlua::Error::runtime(format!(
61 "policy: module '{module}' is not in the allowed set"
62 ))),
63 _ => Ok(()),
64 }
65}
66
67pub fn guard_http(lua: &Lua, method: &str, url: &str) -> mlua::Result<()> {
68 match active(lua) {
69 Some(p) => p
70 .check_http(method, url)
71 .map(|_| ())
72 .map_err(mlua::Error::runtime),
73 None => Ok(()),
74 }
75}
76
77pub fn is_read(lua: &Lua, method: &str, url: &str) -> bool {
80 active(lua)
81 .and_then(|p| p.check_http(method, url).ok())
82 .is_some_and(|c| c == Classification::Read)
83}
84
85pub fn response_limit(lua: &Lua) -> Option<usize> {
86 active(lua).and_then(|p| p.max_response_bytes())
87}
88
89pub fn redact_keys(lua: &Lua) -> Vec<String> {
90 active(lua)
91 .map(|p| p.redact_keys().to_vec())
92 .unwrap_or_default()
93}
94
95pub fn from_env() -> Result<Option<Arc<Policy>>, String> {
96 let Some(path) = std::env::var(POLICY_FILE_ENV)
97 .ok()
98 .filter(|p| !p.is_empty())
99 else {
100 return Ok(None);
101 };
102 Ok(Some(Arc::new(Policy::load(&path)?)))
103}
104
105impl Policy {
106 pub fn load(path: &str) -> Result<Self, String> {
107 let source = std::fs::read_to_string(path)
108 .map_err(|e| format!("policy: cannot read {path}: {e}"))?;
109 Self::parse(&source)
110 }
111
112 pub fn parse(source: &str) -> Result<Self, String> {
113 let file = PolicyFile::parse(source)?;
114 let http = file.http;
115 Ok(Policy {
116 module_allow: file
117 .modules
118 .and_then(|m| m.allow)
119 .map(|list| list.into_iter().collect()),
120 env_allow: file
121 .env
122 .map(|e| e.allow.into_iter().collect::<HashSet<String>>()),
123 max_response_bytes: http.as_ref().and_then(|h| h.max_response_bytes),
124 redact: http.as_ref().map(|h| h.redact.clone()).unwrap_or_default(),
125 http_rules: http.and_then(|h| h.rules),
126 })
127 }
128
129 pub fn module_allowed(&self, module: &str) -> bool {
130 match &self.module_allow {
131 Some(allow) => allow.contains(module),
132 None => true,
133 }
134 }
135
136 pub fn env_visible(&self, key: &str) -> bool {
137 match &self.env_allow {
138 Some(allow) => allow.contains(key),
139 None => true,
140 }
141 }
142
143 pub fn max_response_bytes(&self) -> Option<usize> {
144 self.max_response_bytes
145 }
146
147 pub fn redact_keys(&self) -> &[String] {
148 &self.redact
149 }
150
151 pub fn check_http(&self, method: &str, url: &str) -> Result<Classification, String> {
152 let Some(rules) = &self.http_rules else {
153 return Ok(default_classification(method));
154 };
155 let parsed = url::Url::parse(url)
156 .map_err(|e| format!("policy: cannot parse request URL '{url}': {e}"))?;
157 let host = parsed
158 .host_str()
159 .ok_or_else(|| format!("policy: request URL '{url}' has no host"))?;
160 let path = parsed.path();
161
162 for rule in rules {
163 if rule_matches(rule, host, method, path) {
164 return Ok(match rule.classify {
165 Some(Classify::Read) => Classification::Read,
166 Some(Classify::Write) => Classification::Write,
167 None => default_classification(method),
168 });
169 }
170 }
171 Err(format!(
172 "policy: {} {host}{path} is not allowed by any http rule",
173 method.to_ascii_uppercase()
174 ))
175 }
176}
177
178fn rule_matches(rule: &HttpRule, host: &str, method: &str, path: &str) -> bool {
179 let host_ok = rule.hosts.iter().any(|h| glob::host_matches(h, host));
180 let method_ok = rule.methods.is_empty()
181 || rule
182 .methods
183 .iter()
184 .any(|m| m.trim().eq_ignore_ascii_case(method));
185 let path_ok = rule.paths.is_empty() || rule.paths.iter().any(|p| glob::path_matches(p, path));
186 host_ok && method_ok && path_ok
187}
188
189fn default_classification(method: &str) -> Classification {
190 if method.eq_ignore_ascii_case("get") {
191 Classification::Read
192 } else {
193 Classification::Write
194 }
195}