use crate::models::{V1AuthzConfig, V1UserProfile};
use serde_json::Value;
use std::collections::HashMap;
use tracing::debug;
pub fn expand_pattern(
raw_pattern: &str,
user: &V1UserProfile,
org_id: &str,
org_info: &HashMap<String, String>,
) -> String {
let mut result = raw_pattern.to_string();
result = result.replace("${email}", &user.email);
result = result.replace("${org_id}", org_id);
if let Some(org_name) = org_info.get("org_name") {
result = result.replace("${org_name}", org_name);
}
if let Some(org_role) = org_info.get("org_role") {
result = result.replace("${org_role}", org_role);
}
if let Some(handle) = &user.handle {
result = result.replace("${handle}", handle);
}
result
}
pub fn path_matches(pattern: &str, actual_path: &str) -> bool {
if let Some(stripped) = pattern.strip_suffix("/**") {
actual_path.starts_with(stripped)
} else {
pattern == actual_path
}
}
pub fn field_matches(json_body: &Value, field: &str, pattern: &str) -> bool {
if let Some(val) = json_body.get(field).and_then(|v| v.as_str()) {
if let Some(stripped) = pattern.strip_suffix("/**") {
return val.starts_with(stripped);
} else {
return val == pattern;
}
}
false
}
pub fn evaluate_authorization_rules(
is_allowed: &mut bool,
user_profile: &V1UserProfile,
authz_config: &V1AuthzConfig,
request_path: &str,
json_body_opt: Option<&Value>,
) {
let Some(rules) = &authz_config.rules else {
return;
};
'outer: for rule in rules {
let orgs_map = user_profile
.organizations
.as_ref()
.map(Clone::clone)
.unwrap_or_default();
debug!("[PROXY] Org map: {orgs_map:?}");
let mut rule_matches = false;
for (org_id, org_info) in orgs_map.iter() {
debug!("[PROXY] Org ID: {org_id}, Org info: {org_info:?}");
if let Some(path_match_cfg) = &rule.path_match {
for pm in path_match_cfg {
let expanded = expand_pattern(
pm.pattern.as_deref().unwrap_or(""),
user_profile,
org_id,
org_info,
);
if path_matches(&expanded, request_path) {
rule_matches = true;
break;
}
}
}
debug!("[PROXY] path rule matches: {rule_matches}");
if !rule_matches {
if let Some(field_match_cfg) = &rule.field_match {
if let Some(json_body) = json_body_opt {
for fm in field_match_cfg {
let expanded = expand_pattern(
fm.pattern.as_deref().unwrap_or(""),
user_profile,
org_id,
org_info,
);
if field_matches(
json_body,
fm.json_path.as_deref().unwrap_or(""),
&expanded,
) {
rule_matches = true;
}
}
}
}
}
debug!("[PROXY] field rule matches: {rule_matches}");
if rule_matches {
*is_allowed = rule.allow;
break 'outer;
}
}
}
}
pub fn extract_json_path(json_obj: &Value, json_path: &str) -> Option<Value> {
let compiled = jsonpath_lib::Compiled::compile(json_path).ok()?;
let results = compiled.select(json_obj).ok()?;
if let Some(value) = results.get(0) {
Some((*value).clone())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{
V1AuthzConfig, V1AuthzFieldMatch, V1AuthzPathMatch, V1AuthzRule, V1UserProfile,
};
use serde_json::json;
use std::collections::HashMap;
#[test]
fn test_expand_pattern() {
let user_profile = V1UserProfile {
email: String::from("john.doe@example.com"),
handle: Some(String::from("john_handle")),
organizations: None,
..Default::default()
};
let org_id = "org-123";
let mut org_info = HashMap::new();
org_info.insert("org_name".to_string(), "Example Org".to_string());
org_info.insert("org_role".to_string(), "Admin".to_string());
let raw_pattern = "User: ${email}, OrgID: ${org_id}, OrgName: ${org_name}, Role: ${org_role}, Handle: ${handle}";
let result = expand_pattern(raw_pattern, &user_profile, org_id, &org_info);
assert_eq!(
result,
"User: john.doe@example.com, OrgID: org-123, OrgName: Example Org, Role: Admin, Handle: john_handle"
);
}
#[test]
fn test_path_matches() {
assert!(path_matches("/api/v1/resource", "/api/v1/resource"));
assert!(!path_matches("/api/v1/resource", "/api/v1/non-matching"));
assert!(path_matches("/api/v1/resource/**", "/api/v1/resource"));
assert!(path_matches(
"/api/v1/resource/**",
"/api/v1/resource/subpath"
));
assert!(path_matches("/api/v1/**", "/api/v1/resource/subpath"));
assert!(!path_matches("/api/v2/**", "/api/v1/resource"));
}
#[test]
fn test_field_matches() {
let json_body = json!({
"action": "create",
"resource": "repo/subpath",
"notes": "some notes"
});
assert!(field_matches(&json_body, "action", "create"));
assert!(!field_matches(&json_body, "action", "delete"));
assert!(field_matches(&json_body, "resource", "repo/**"));
assert!(!field_matches(&json_body, "resource", "other/**"));
}
#[test]
fn test_evaluate_authorization_rules_path_match_allows() {
let mut is_allowed = false;
let user_profile = V1UserProfile {
email: String::from("alice@example.com"),
handle: None,
organizations: Some({
let mut map = HashMap::new();
map.insert("org-abc".to_string(), {
let mut org_info = HashMap::new();
org_info.insert("org_name".to_string(), "TestOrg".to_string());
org_info
});
map
}),
..Default::default()
};
let rules = vec![V1AuthzRule {
name: "test".to_string(),
allow: true, path_match: Some(vec![V1AuthzPathMatch {
pattern: Some("/api/v1/**".to_string()),
path: None,
}]),
field_match: None,
rule_match: None,
}];
let authz_config = V1AuthzConfig {
enabled: true,
default_action: "allow".to_string(),
auth_type: "jwt".to_string(),
jwt: None,
rules: Some(rules),
};
evaluate_authorization_rules(
&mut is_allowed,
&user_profile,
&authz_config,
"/api/v1/some-resource",
None,
);
assert!(is_allowed, "Should be allowed by matching path rule.");
}
#[test]
fn test_evaluate_authorization_rules_field_match_denies() {
let mut is_allowed = true; let user_profile = V1UserProfile {
email: String::from("bob@example.com"),
handle: Some("bob_handle".to_string()),
organizations: Some({
let mut map = HashMap::new();
map.insert("org-lmn".to_string(), {
let mut org_info = HashMap::new();
org_info.insert("org_role".to_string(), "Viewer".to_string());
org_info
});
map
}),
..Default::default()
};
let rules = vec![
V1AuthzRule {
name: "test1".to_string(),
allow: false,
rule_match: None,
path_match: None,
field_match: Some(vec![V1AuthzFieldMatch {
json_path: Some("action".to_string()),
pattern: Some("delete".to_string()),
}]),
},
];
let authz_config = V1AuthzConfig {
enabled: true,
default_action: "allow".to_string(),
auth_type: "jwt".to_string(),
jwt: None,
rules: Some(rules),
};
let request_path = "/api/other"; let json_body = json!({
"action": "delete",
});
evaluate_authorization_rules(
&mut is_allowed,
&user_profile,
&authz_config,
request_path,
Some(&json_body),
);
assert!(!is_allowed, "Rule should deny due to field match.");
}
#[test]
fn test_extract_json_path() {
let json_obj = json!({
"parent": {
"child": {
"value": 123,
"list": [10, 20, 30]
}
}
});
let found_value = extract_json_path(&json_obj, "$.parent.child.value");
let found_list_item = extract_json_path(&json_obj, "$.parent.child.list[1]");
let not_found = extract_json_path(&json_obj, "$.parent.nonexistent");
assert_eq!(found_value, Some(json!(123)));
assert_eq!(found_list_item, Some(json!(20)));
assert_eq!(not_found, None);
}
}