athena-gateway 3.18.0

Portable gateway request contracts and normalization primitives for Athena
Documentation
//! Portable gateway authorization right helpers.
//!
//! These helpers define the stable right names used across gateway routes and
//! implement wildcard matching independently of the runtime API-key store.

/// Required read right for a specific resource or the gateway-wide fallback.
pub fn read_right_for_resource(resource: Option<&str>) -> String {
    resource_right(resource, "read", "gateway.read")
}

/// Required write right for a specific resource or the gateway-wide fallback.
pub fn write_right_for_resource(resource: Option<&str>) -> String {
    resource_right(resource, "write", "gateway.write")
}

/// Required delete right for a specific resource or the gateway-wide fallback.
pub fn delete_right_for_resource(resource: Option<&str>) -> String {
    resource_right(resource, "delete", "gateway.delete")
}

/// Right required for `/gateway/query`.
pub fn query_right() -> String {
    "gateway.query".to_string()
}

/// Right required for `/storage/*` proxy operations.
pub fn storage_proxy_right() -> String {
    "gateway.storage_proxy".to_string()
}

/// Right required for `/typesense/*` proxy/search operations.
pub fn typesense_proxy_right() -> String {
    "gateway.typesense_proxy".to_string()
}

/// Right required for `/gateway/rpc`.
pub fn rpc_right() -> String {
    "gateway.rpc.execute".to_string()
}

fn resource_right(resource: Option<&str>, action: &str, fallback: &str) -> String {
    resource
        .and_then(|value| {
            if value.contains('.') {
                return None;
            }
            sanitize_identifier(value).map(|sanitized| sanitized.trim_matches('"').to_string())
        })
        .map(|value| format!("{}.{}", value, action))
        .unwrap_or_else(|| fallback.to_string())
}

fn split_right(right: &str) -> Option<(&str, &str)> {
    right.split_once('.')
}

/// Returns true when `granted` satisfies `required`, including wildcard forms.
pub fn right_matches(granted: &str, required: &str) -> bool {
    if granted == "*" || granted == required {
        return true;
    }

    let Some((granted_resource, granted_action)) = split_right(granted) else {
        return false;
    };
    let Some((required_resource, required_action)) = split_right(required) else {
        return false;
    };

    if granted_resource == "*" && granted_action == required_action {
        return true;
    }
    if granted_resource == required_resource && granted_action == "*" {
        return true;
    }
    if granted_resource == "gateway" && granted_action == required_action {
        return true;
    }
    if granted_resource == "gateway" && granted_action == "*" {
        return true;
    }

    false
}

/// Returns the subset of `required_rights` not covered by `granted_rights`.
pub fn missing_required_rights(
    granted_rights: &[String],
    required_rights: &[String],
) -> Vec<String> {
    required_rights
        .iter()
        .filter(|required| {
            !granted_rights
                .iter()
                .any(|granted| right_matches(granted, required))
        })
        .cloned()
        .collect()
}

fn sanitize_identifier(identifier: &str) -> Option<String> {
    let mut chars = identifier.chars();
    let first = chars.next()?;
    if !(first.is_ascii_alphabetic() || first == '_') {
        return None;
    }
    if !chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
        return None;
    }
    Some(format!("\"{identifier}\""))
}

#[cfg(test)]
mod tests {
    use super::{
        delete_right_for_resource, missing_required_rights, query_right, read_right_for_resource,
        right_matches, rpc_right, storage_proxy_right, typesense_proxy_right,
        write_right_for_resource,
    };

    #[test]
    fn wildcard_rights_match_expected_shapes() {
        assert!(right_matches("users.read", "users.read"));
        assert!(right_matches("users.*", "users.read"));
        assert!(right_matches("*.read", "users.read"));
        assert!(right_matches("gateway.read", "users.read"));
        assert!(right_matches("gateway.*", "users.delete"));
        assert!(right_matches("*", "users.read"));
        assert!(!right_matches("users.write", "users.read"));
        assert!(!right_matches("tickets.read", "users.read"));
    }

    #[test]
    fn resource_right_helpers_fall_back_to_gateway_scopes() {
        assert_eq!(read_right_for_resource(Some("users")), "users.read");
        assert_eq!(write_right_for_resource(Some("users")), "users.write");
        assert_eq!(delete_right_for_resource(Some("users")), "users.delete");
        assert_eq!(
            read_right_for_resource(Some("public.users")),
            "gateway.read"
        );
        assert_eq!(query_right(), "gateway.query");
        assert_eq!(rpc_right(), "gateway.rpc.execute");
        assert_eq!(storage_proxy_right(), "gateway.storage_proxy");
        assert_eq!(typesense_proxy_right(), "gateway.typesense_proxy");
    }

    #[test]
    fn missing_required_rights_reports_only_uncovered_entries() {
        let granted = vec!["users.*".to_string(), "gateway.query".to_string()];
        let required = vec![
            "users.read".to_string(),
            "users.write".to_string(),
            "tickets.read".to_string(),
            "gateway.query".to_string(),
        ];

        assert_eq!(
            missing_required_rights(&granted, &required),
            vec!["tickets.read".to_string()]
        );
    }
}