use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
#[derive(Debug, Clone)]
pub struct AgentAttenuation {
pub agent_id: String,
pub expires_at: DateTime<Utc>,
pub allowed_operations: Option<Vec<String>>,
pub allowed_resources: Option<Vec<(String, String)>>,
}
impl AgentAttenuation {
pub fn time_bounded(agent_id: impl Into<String>, expires_at: DateTime<Utc>) -> Self {
Self {
agent_id: agent_id.into(),
expires_at,
allowed_operations: None,
allowed_resources: None,
}
}
}
pub fn attenuate_for_agent(
parent_token_b64: &str,
restrictions: AgentAttenuation,
) -> Result<String> {
let unverified = biscuit_auth::UnverifiedBiscuit::from_base64(parent_token_b64.as_bytes())
.context("parse parent biscuit (unverified)")?;
let block = build_attenuation_block(&restrictions)?;
let attenuated = unverified
.append(block)
.context("append attenuation block")?;
attenuated.to_base64().context("encode attenuated biscuit")
}
fn build_attenuation_block(
restrictions: &AgentAttenuation,
) -> Result<biscuit_auth::builder::BlockBuilder> {
let mut block = biscuit_auth::builder::BlockBuilder::new();
block = block
.fact(format!("agent({})", biscuit_string(&restrictions.agent_id)).as_str())
.context("agent fact")?;
block = block
.check(
format!(
"check if time($now), $now < {}",
restrictions.expires_at.to_rfc3339()
)
.as_str(),
)
.context("expiry check")?;
if let Some(ops) = &restrictions.allowed_operations
&& !ops.is_empty()
{
let pred = ops
.iter()
.map(|op| format!("$op == {}", biscuit_string(op)))
.collect::<Vec<_>>()
.join(" || ");
block = block
.check(format!("check if operation($op), {pred}").as_str())
.context("operation allowlist check")?;
}
if let Some(resources) = &restrictions.allowed_resources
&& !resources.is_empty()
{
let mut clauses = Vec::new();
for (kind, path) in resources {
let prefix = format!("{path}/");
clauses.push(format!(
"($k == {kind_lit} && ($p == {path_lit} || $p.starts_with({prefix_lit})))",
kind_lit = biscuit_string(kind),
path_lit = biscuit_string(path),
prefix_lit = biscuit_string(&prefix),
));
}
let pred = clauses.join(" || ");
block = block
.check(format!("check if resource($k, $p), {pred}").as_str())
.context("resource allowlist check")?;
}
Ok(block)
}
fn biscuit_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
_ => out.push(ch),
}
}
out.push('"');
out
}
pub fn time_bounded(
parent_token_b64: &str,
agent_id: impl Into<String>,
expires_at: DateTime<Utc>,
) -> Result<String> {
attenuate_for_agent(
parent_token_b64,
AgentAttenuation::time_bounded(agent_id, expires_at),
)
}
pub fn read_only_repo_agent(
parent_token_b64: &str,
agent_id: impl Into<String>,
repo_path: impl Into<String>,
duration_hours: i64,
) -> Result<String> {
attenuate_for_agent(
parent_token_b64,
AgentAttenuation {
agent_id: agent_id.into(),
expires_at: Utc::now() + chrono::Duration::hours(duration_hours),
allowed_operations: Some(vec![
"GetState".to_string(),
"GetTree".to_string(),
"GetBlob".to_string(),
"GetCompare".to_string(),
"GetDiff".to_string(),
"ListRefs".to_string(),
"ListStates".to_string(),
"ListContext".to_string(),
]),
allowed_resources: Some(vec![("repo".to_string(), repo_path.into())]),
},
)
}
#[cfg(test)]
mod tests {
use biscuit_auth::KeyPair;
use super::*;
fn fresh_parent_token() -> (String, KeyPair) {
let kp = KeyPair::new();
let mut builder = biscuit_auth::Biscuit::builder();
builder = builder.fact(r#"user("alice")"#).expect("user fact");
builder = builder.fact(r#"session("sess-1")"#).expect("session fact");
let exp = chrono::Utc::now() + chrono::Duration::hours(2);
builder = builder
.fact(format!("expires_at({})", exp.to_rfc3339()).as_str())
.expect("expires_at fact");
builder = builder
.check(format!("check if time($now), $now < {}", exp.to_rfc3339()).as_str())
.expect("expiry check");
let biscuit = builder.build(&kp).expect("build parent biscuit");
(biscuit.to_base64().expect("to_base64"), kp)
}
#[test]
fn attenuate_appends_a_block_with_agent_marker() {
let (parent, _kp) = fresh_parent_token();
let attenuated = time_bounded(&parent, "agent-1", Utc::now() + chrono::Duration::hours(2))
.expect("attenuate");
assert!(attenuated.len() > parent.len());
}
#[test]
fn time_bounded_with_past_expiry_still_attenuates() {
let (parent, _kp) = fresh_parent_token();
let result = time_bounded(&parent, "agent-1", Utc::now() - chrono::Duration::hours(1));
assert!(result.is_ok());
}
#[test]
fn read_only_repo_agent_builds_with_op_and_resource_restrictions() {
let (parent, _kp) = fresh_parent_token();
let attenuated =
read_only_repo_agent(&parent, "agent-r", "org/acme/heddle", 2).expect("attenuate");
let parsed =
biscuit_auth::UnverifiedBiscuit::from_base64(attenuated.as_bytes()).expect("parse");
assert!(parsed.block_count() >= 2, "expected attenuation block");
}
}