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