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