Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,12 @@ When `capabilities_header` is set, Lacuna expects a header that follows the Tail

```json
{
"flare.io/cap/lacuna": [
"flare.io/cap/lacuna/grants": [
{ "providers": ["firstprovider", "secondprovider"] },
{ "providers": ["thirdprovider-*"], "models": ["model-1"] }
],
"flare.io/cap/lacuna/labels": [
{ "team": "platform", "env": "production" }
]
}
```
Expand All @@ -97,6 +100,8 @@ In the above example, the user may:
- Use all models of `firstprovider` and `secondprovider`.
- Use `model-1` in any provider that starts with `thirdprovider-`.

The `flare.io/cap/lacuna/labels` key carries flat key-value metadata that is attached to Prometheus metrics and traces for observability.

Notes:
- Providers and models may contain glob patterns.
- Empty lists and omitted values default to `["*"]`.
Expand Down
2 changes: 1 addition & 1 deletion examples/tailscale/acl_grants.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
],
"dst": ["tag:lacuna"],
"app": {
"flare.io/cap/lacuna": [
"flare.io/cap/lacuna/grants": [
{ "providers": ["firstprovider", "secondprovider"] },
{ "providers": ["thirdprovider-*"], "models": ["model-1"]
]
Expand Down
2 changes: 2 additions & 0 deletions src/authorization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ mod tests {
inspected: RequestInspectionMetadata {
model: model.map(|m| m.to_string()),
},
labels: std::collections::HashMap::new(),
}
}

Expand All @@ -87,6 +88,7 @@ mod tests {
normalized: ua.to_string(),
}),
inspected: RequestInspectionMetadata::default(),
labels: std::collections::HashMap::new(),
}
}

Expand Down
161 changes: 97 additions & 64 deletions src/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ use serde::Deserialize;
use serde::Serialize;
use serde_with::DefaultOnError;
use serde_with::serde_as;
use std::collections::HashMap;

#[derive(PartialEq, Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Capabilities {
pub capabilities: Vec<Capability>,
pub grants: Vec<Grant>,
pub labels: HashMap<String, String>,
}

#[derive(PartialEq, Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Capability {
pub struct Grant {
#[serde(
serialize_with = "crate::serde_utils::serialize_patterns",
deserialize_with = "crate::serde_utils::deserialize_patterns"
Expand All @@ -32,13 +34,10 @@ pub struct Capability {
}

impl Capabilities {
pub fn from_capabilities(capabilities: Vec<Capability>) -> Self {
Self { capabilities }
}

pub fn deny_all() -> Self {
Self {
capabilities: vec![],
grants: vec![],
labels: HashMap::new(),
}
}
}
Expand All @@ -47,7 +46,7 @@ impl From<Capabilities> for crate::authorization::Authorization {
fn from(caps: Capabilities) -> Self {
Self {
rules: caps
.capabilities
.grants
.into_iter()
.map(|c| crate::authorization::Rule {
providers: c.providers,
Expand All @@ -67,8 +66,12 @@ impl From<Capabilities> for crate::authorization::Authorization {
#[derive(Debug, Deserialize)]
struct TailscaleCapabilities {
#[serde_as(as = "Vec<DefaultOnError<Option<_>>>")]
#[serde(default, rename = "flare.io/cap/lacuna")]
app_capabilities: Vec<Option<Capability>>,
#[serde(default, rename = "flare.io/cap/lacuna/grants")]
grants: Vec<Option<Grant>>,

#[serde_as(as = "Vec<DefaultOnError<Option<_>>>")]
#[serde(default, rename = "flare.io/cap/lacuna/labels")]
labels: Vec<Option<HashMap<String, String>>>,
}

pub fn parse_capabilities(header_value: &str) -> Result<Capabilities, anyhow::Error> {
Expand All @@ -84,13 +87,28 @@ pub fn parse_capabilities(header_value: &str) -> Result<Capabilities, anyhow::Er
}
};

let capabilities = Capabilities::from_capabilities(
ts_capabilities
.app_capabilities
.into_iter()
.flatten()
.collect(),
);
let capabilities = Capabilities {
grants: ts_capabilities.grants.into_iter().flatten().collect(),
labels: {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
// Collect labels from list of capabilities.
for (k, v) in ts_capabilities
.labels
.into_iter()
.flatten()
.flat_map(|m| m.into_iter())
{
map.entry(k).or_default().push(v);
}
// Merge label with the same key with a ",".join
map.into_iter()
.map(|(k, mut vs)| {
vs.sort();
(k, vs.join(","))
})
.collect()
},
};

Ok(capabilities)
}
Expand All @@ -104,12 +122,12 @@ mod tests {
}

#[test]
fn test_capability_deserialize() {
fn test_grant_deserialize() {
let json = r#"{"providers": ["myprovider", "prefix-*"], "models": ["claude-*"], "user_agents": ["python-*"]}"#;
let capability: Capability = serde_json::from_str(json).unwrap();
let grant: Grant = serde_json::from_str(json).unwrap();
assert_eq!(
capability,
Capability {
grant,
Grant {
providers: vec![pattern("myprovider"), pattern("prefix-*")],
models: vec![pattern("claude-*")],
user_agents: vec![pattern("python-*")],
Expand All @@ -118,12 +136,12 @@ mod tests {
}

#[test]
fn test_capability_deserialize_no_models() {
fn test_grant_deserialize_no_models() {
let json = r#"{"providers": ["myprovider"]}"#;
let capability: Capability = serde_json::from_str(json).unwrap();
let grant: Grant = serde_json::from_str(json).unwrap();
assert_eq!(
capability,
Capability {
grant,
Grant {
providers: vec![pattern("myprovider")],
models: vec![],
user_agents: vec![],
Expand All @@ -132,9 +150,9 @@ mod tests {
}

#[test]
fn test_capability_deserialize_invalid_pattern() {
fn test_grant_deserialize_invalid_pattern() {
let json = r#"{"providers": ["valid", "[invalid"]}"#;
let result: Result<Capability, _> = serde_json::from_str(json);
let result: Result<Grant, _> = serde_json::from_str(json);
assert!(
result
.unwrap_err()
Expand All @@ -144,13 +162,13 @@ mod tests {
}

#[test]
fn test_capability_serialize() {
let capability = Capability {
fn test_grant_serialize() {
let grant = Grant {
providers: vec![pattern("myprovider"), pattern("prefix-*")],
models: vec![pattern("claude-*")],
user_agents: vec![pattern("python-*")],
};
let json = serde_json::to_value(&capability).unwrap();
let json = serde_json::to_value(&grant).unwrap();
assert_eq!(
json,
serde_json::json!({
Expand All @@ -164,33 +182,43 @@ mod tests {
#[test]
fn parse_valid_capabilities() {
let json = r#"{
"flare.io/cap/lacuna": [
"flare.io/cap/lacuna/grants": [
{"providers": ["firstprovider"], "models": ["claude-*"], "user_agents": ["python-*"]},
{"providers": ["secondprofider", "thirdprovider"], "models": ["gpt-*"]}
],
"flare.io/cap/lacuna/labels": [
{"team": "platform", "env": "production"},
{"team": "infra"}
]
}"#;
let capabilities = parse_capabilities(json).unwrap();
assert_eq!(
capabilities,
Capabilities::from_capabilities(vec![
Capability {
providers: vec![pattern("firstprovider")],
models: vec![pattern("claude-*")],
user_agents: vec![pattern("python-*")],
},
Capability {
providers: vec![pattern("secondprofider"), pattern("thirdprovider")],
models: vec![pattern("gpt-*")],
user_agents: vec![],
},
]),
Capabilities {
grants: vec![
Grant {
providers: vec![pattern("firstprovider")],
models: vec![pattern("claude-*")],
user_agents: vec![pattern("python-*")],
},
Grant {
providers: vec![pattern("secondprofider"), pattern("thirdprovider")],
models: vec![pattern("gpt-*")],
user_agents: vec![],
},
],
labels: HashMap::from([
("team".to_owned(), "infra,platform".to_owned()),
("env".to_owned(), "production".to_owned()),
]),
}
);
}

#[test]
fn parse_capabilities_invalid_ignored() {
let json = r#"{
"flare.io/cap/lacuna": [
"flare.io/cap/lacuna/grants": [
{"providers": ["firstprovider"], "models": ["claude-*"]},
["something-bad"],
{"providers": ["secondprofider"]}
Expand All @@ -199,18 +227,21 @@ mod tests {
let capabilities = parse_capabilities(json).unwrap();
assert_eq!(
capabilities,
Capabilities::from_capabilities(vec![
Capability {
providers: vec![pattern("firstprovider")],
models: vec![pattern("claude-*")],
user_agents: vec![],
},
Capability {
providers: vec![pattern("secondprofider")],
models: vec![],
user_agents: vec![],
},
]),
Capabilities {
grants: vec![
Grant {
providers: vec![pattern("firstprovider")],
models: vec![pattern("claude-*")],
user_agents: vec![],
},
Grant {
providers: vec![pattern("secondprofider")],
models: vec![],
user_agents: vec![],
},
],
labels: Default::default(),
}
);
}

Expand All @@ -230,17 +261,19 @@ mod tests {
fn parse_rfc2047_encoded() {
// Tailscale uses RFC2047 "Q" encoding for values that contain non-ASCII characters.
// Ref: https://tailscale.com/docs/features/tailscale-serve#app-capabilities-header
// Q-encoded: {"flare.io/cap/lacuna":[{"providers":["🐿️"]}]}
let encoded =
r#"=?utf-8?q?{"flare.io/cap/lacuna":[{"providers":["=F0=9F=90=BF=EF=B8=8F"]}]}?="#;
// Q-encoded: {"flare.io/cap/lacuna/grants":[{"providers":["🐿️"]}]}
let encoded = r#"=?utf-8?q?{"flare.io/cap/lacuna/grants":[{"providers":["=F0=9F=90=BF=EF=B8=8F"]}]}?="#;
let capabilities = parse_capabilities(encoded).unwrap();
assert_eq!(
capabilities,
Capabilities::from_capabilities(vec![Capability {
providers: vec![pattern("🐿️")],
models: vec![],
user_agents: vec![],
},]),
Capabilities {
grants: vec![Grant {
providers: vec![pattern("🐿️")],
models: vec![],
user_agents: vec![],
},],
labels: Default::default()
},
);
}
}
3 changes: 3 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ pub struct Provider {

#[serde(default)]
pub headers: HashMap<String, String>,

#[serde(default)]
pub labels: HashMap<String, String>,
}

#[derive(Debug, Deserialize, Serialize, Default, PartialEq)]
Expand Down
19 changes: 16 additions & 3 deletions src/http_handlers/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,32 @@ async fn try_forward_to_provider(
None => Default::default(),
};

let mut labels = provider.labels.clone();
let caps = capabilities::get_capabilities(&request);
if let Some(ref c) = caps {
for (k, v) in &c.labels {
labels.insert(k.clone(), v.clone());
}
}

if !labels.is_empty() {
tracing::Span::current().record("request_labels", tracing::field::debug(&labels));
}

let request_metadata = RequestMetadata {
provider_key: provider.key.clone(),
api_handler_id: api_type_handler_id,
user_identity: user,
user_agent,
inspected: request_inspection_metadata,
labels,
};

if !provider.authorizer.is_allowed(&request_metadata) {
return forbidden_response("request not allowed by provider");
}

if let Some(caps) = capabilities::get_capabilities(&request)
if let Some(caps) = caps
&& !Authorization::from(caps.clone()).is_allowed(&request_metadata)
{
return capabilities_forbidden_response("request not allowed by capabilities", &caps);
Expand Down Expand Up @@ -174,7 +187,7 @@ fn capabilities_forbidden_response(
) -> anyhow::Result<Response> {
let body = serde_json::json!({
"error": error,
"capabilities": capabilities.capabilities,
"capabilities": capabilities.grants,
});
let resp = Response::builder()
.status(StatusCode::FORBIDDEN)
Expand Down Expand Up @@ -362,7 +375,7 @@ mod tests {

// Request with the capabilities header granting access — should succeed.
let caps_header = serde_json::json!({
"flare.io/cap/lacuna": [
"flare.io/cap/lacuna/grants": [
{
"providers": ["myprovider"],
"models": ["us.anthropic.claude-*"]
Expand Down
Loading
Loading