Skip to main content

assay/lua/policy/
credential.rs

1//! Credential handles the VM cannot read.
2//!
3//! `credential.get(name)` hands back opaque placeholders, not secrets. The
4//! real values are substituted into the outgoing request by the HTTP wrapper,
5//! after the policy has already decided the target is allowed — so a script
6//! can compose a request that authenticates without ever holding the secret.
7
8use mlua::{Lua, Value};
9
10use super::Policy;
11
12const MARK: char = '\u{1}';
13const TAG: &str = "assay-cred";
14
15pub fn placeholder(name: &str, field: &str) -> String {
16    format!("{MARK}{TAG}{MARK}{name}{MARK}{field}{MARK}")
17}
18
19pub fn contains_placeholder(text: &str) -> bool {
20    text.contains(MARK) && text.contains(TAG)
21}
22
23/// Register the global `credential` table. Only names the policy declares
24/// resolve; anything else is an error rather than a silent empty handle.
25pub fn register(lua: &Lua, policy: &Policy) -> mlua::Result<()> {
26    let declared: Vec<(String, Vec<String>)> = policy
27        .credentials
28        .iter()
29        .map(|(name, fields)| (name.clone(), fields.keys().cloned().collect()))
30        .collect();
31
32    let get = lua.create_function(move |lua, name: String| {
33        let Some((_, fields)) = declared.iter().find(|(n, _)| *n == name) else {
34            return Err(mlua::Error::runtime(format!(
35                "credential: '{name}' is not declared in the policy"
36            )));
37        };
38        let handle = lua.create_table()?;
39        for field in fields {
40            handle.set(field.as_str(), placeholder(&name, field))?;
41        }
42        Ok(handle)
43    })?;
44
45    let table = lua.create_table()?;
46    table.set("get", get)?;
47    lua.globals().set("credential", table)
48}
49
50/// Replace placeholders with the real values a moment before the request
51/// leaves. Walks nested tables so a module that builds a JSON body out of
52/// its options table is covered without changing that module.
53pub fn substitute(lua: &Lua, policy: &Policy, value: Value) -> mlua::Result<Value> {
54    match value {
55        Value::String(s) => {
56            let text = s.to_str()?.to_string();
57            if !contains_placeholder(&text) {
58                return Ok(Value::String(s));
59            }
60            Ok(Value::String(lua.create_string(expand(policy, &text))?))
61        }
62        Value::Table(t) => {
63            let out = lua.create_table()?;
64            for pair in t.pairs::<Value, Value>() {
65                let (k, v) = pair?;
66                out.set(k, substitute(lua, policy, v)?)?;
67            }
68            Ok(Value::Table(out))
69        }
70        other => Ok(other),
71    }
72}
73
74fn expand(policy: &Policy, text: &str) -> String {
75    let mut out = text.to_string();
76    for (name, fields) in &policy.credentials {
77        for (field, env_key) in fields {
78            let token = placeholder(name, field);
79            if !out.contains(&token) {
80                continue;
81            }
82            let resolved = std::env::var(env_key).unwrap_or_default();
83            out = out.replace(&token, &resolved);
84        }
85    }
86    out
87}
88
89/// A placeholder in a URL would put the secret in a request line, and from
90/// there into every access log on the path. Refuse instead of substituting.
91pub fn reject_in_url(url: &str) -> mlua::Result<()> {
92    if contains_placeholder(url) {
93        return Err(mlua::Error::runtime(
94            "credential: a credential handle cannot be used in a URL",
95        ));
96    }
97    Ok(())
98}