use ankql::ast::{Expr, Literal, Predicate};
use ankurah_core::policy::AccessDenied;
use ankurah_proto::EntityId;
use crate::claims::JwtClaims;
fn resolve_variable<'a>(var: &str, claims: &'a JwtClaims) -> Result<String, AccessDenied> {
match var {
"$jwt.sub" => Ok(claims.sub.clone()),
"$jwt.email" => Ok(claims.email.clone()),
"$jwt.name" => claims.name.clone().ok_or(AccessDenied::ByPolicy("Missing name claim for $jwt.name")),
_ if var.starts_with("$jwt.custom.") => {
let field = &var["$jwt.custom.".len()..];
match claims.custom.get(field) {
Some(serde_json::Value::String(s)) => Ok(s.clone()),
Some(_) => Err(AccessDenied::ByPolicy("Non-string custom claim values are not supported in scope filters")),
None => Err(AccessDenied::ByPolicy("Missing claim for variable")),
}
}
_ => Err(AccessDenied::ByPolicy("Unknown variable")),
}
}
fn typed_expr(value: String) -> Expr {
match EntityId::from_base64(&value) {
Ok(id) => Expr::from(&id),
Err(_) => Expr::Literal(Literal::String(value)),
}
}
pub fn parse_and_substitute(filter_str: &str, claims: &JwtClaims) -> Result<Predicate, AccessDenied> {
let mut values = Vec::new();
let mut query = String::with_capacity(filter_str.len());
let mut rest = filter_str;
while let Some(start) = rest.find("$jwt.") {
query.push_str(&rest[..start]);
let tail = &rest[start..];
let token_len = tail
.char_indices()
.skip(1) .find(|(_, c)| !c.is_alphanumeric() && *c != '.' && *c != '_')
.map(|(i, _)| i)
.unwrap_or(tail.len());
values.push(resolve_variable(&tail[..token_len], claims)?);
query.push('?');
rest = &tail[token_len..];
}
query.push_str(rest);
let selection = ankql::parser::parse_selection(&query).map_err(AccessDenied::ParseError)?;
selection.predicate.populate(values.into_iter().map(typed_expr)).map_err(AccessDenied::ParseError)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_claims(sub: &str) -> JwtClaims {
JwtClaims {
sub: sub.to_string(),
roles: vec![],
email: "test@example.com".to_string(),
name: Some("Test User".to_string()),
custom: serde_json::Map::new(),
}
}
fn test_claims_with_custom(sub: &str, key: &str, value: &str) -> JwtClaims {
let mut custom = serde_json::Map::new();
custom.insert(key.to_string(), serde_json::Value::String(value.to_string()));
JwtClaims {
sub: sub.to_string(),
roles: vec![],
email: "test@example.com".to_string(),
name: Some("Test User".to_string()),
custom,
}
}
fn rhs_literal(predicate: &Predicate) -> &Literal {
match predicate {
Predicate::Comparison { right, .. } => match right.as_ref() {
Expr::Literal(lit) => lit,
other => panic!("Expected literal rhs, got {:?}", other),
},
other => panic!("Expected comparison, got {:?}", other),
}
}
#[test]
fn test_substitute_jwt_sub() {
let claims = test_claims("user123");
let result = parse_and_substitute("author = $jwt.sub", &claims).unwrap();
assert_eq!(rhs_literal(&result), &Literal::String("user123".to_string()));
}
#[test]
fn test_substitute_jwt_email() {
let claims = test_claims("user123");
let result = parse_and_substitute("contact = $jwt.email", &claims).unwrap();
assert_eq!(rhs_literal(&result), &Literal::String("test@example.com".to_string()));
}
#[test]
fn test_substitute_jwt_custom() {
let claims = test_claims_with_custom("user123", "field_office", "NYC");
let result = parse_and_substitute("office = $jwt.custom.field_office", &claims).unwrap();
assert_eq!(rhs_literal(&result), &Literal::String("NYC".to_string()));
}
#[test]
fn test_missing_custom_claim() {
let claims = test_claims("user123");
let result = parse_and_substitute("office = $jwt.custom.nonexistent", &claims);
assert!(result.is_err(), "Should fail for missing custom claim");
}
#[test]
fn test_unknown_variable() {
let claims = test_claims("user123");
let result = parse_and_substitute("x = $jwt.unknown_field", &claims);
assert!(result.is_err(), "Should fail for unknown variable");
}
#[test]
fn test_substitute_jwt_name() {
let claims = test_claims("user123");
let result = parse_and_substitute("author_name = $jwt.name", &claims).unwrap();
assert_eq!(rhs_literal(&result), &Literal::String("Test User".to_string()));
}
#[test]
fn test_substitute_jwt_name_missing_fails() {
let mut claims = test_claims("user123");
claims.name = None;
let result = parse_and_substitute("author_name = $jwt.name", &claims);
assert!(result.is_err(), "Missing name claim should fail-closed");
}
#[test]
fn test_substitute_special_chars_in_sub() {
let claims = test_claims("user-123_test@example.com");
let result = parse_and_substitute("author = $jwt.sub", &claims).unwrap();
assert_eq!(rhs_literal(&result), &Literal::String("user-123_test@example.com".to_string()));
}
#[test]
fn test_injection_payload_becomes_inert_literal() {
let payload = "'; DROP TABLE posts; --";
let claims = test_claims(payload);
let result = parse_and_substitute("author = $jwt.sub", &claims).unwrap();
assert_eq!(rhs_literal(&result), &Literal::String(payload.to_string()));
}
#[test]
fn test_apostrophe_value_is_inert() {
let mut claims = test_claims("user123");
claims.name = Some("Miles O'Brien".to_string());
let result = parse_and_substitute("author_name = $jwt.name", &claims).unwrap();
assert_eq!(rhs_literal(&result), &Literal::String("Miles O'Brien".to_string()));
}
#[test]
fn test_entity_id_value_becomes_typed_literal() {
let id = ankurah_proto::EntityId::new();
let claims = test_claims(&id.to_base64());
let result = parse_and_substitute("owner = $jwt.sub", &claims).unwrap();
assert_eq!(rhs_literal(&result), &Literal::EntityId(id.to_ulid()));
}
#[test]
fn test_literal_placeholder_in_filter_fails_closed() {
let claims = test_claims("user123");
let result = parse_and_substitute("author = $jwt.sub AND status = ?", &claims);
assert!(result.is_err(), "Literal placeholder in filter must fail-closed");
}
#[test]
fn test_non_string_custom_claim_rejected() {
let mut claims = test_claims("user123");
claims.custom.insert("count".to_string(), serde_json::Value::Number(42.into()));
let result = parse_and_substitute("x = $jwt.custom.count", &claims);
assert!(result.is_err(), "Non-string custom claim values must be rejected");
}
#[test]
fn test_substitute_multiple_variables() {
let mut custom = serde_json::Map::new();
custom.insert("office".to_string(), serde_json::Value::String("NYC".to_string()));
let claims = JwtClaims { sub: "user123".to_string(), roles: vec![], email: "test@example.com".to_string(), name: None, custom };
let result = parse_and_substitute("author = $jwt.sub AND office = $jwt.custom.office", &claims).unwrap();
let display = format!("{}", result);
assert!(display.contains("user123"), "Expected 'user123' in: {}", display);
assert!(display.contains("NYC"), "Expected 'NYC' in: {}", display);
}
}