cloud-sdk-reqwest 0.30.1

Optional provider-neutral reqwest transport boundary for cloud-sdk.
Documentation
use cloud_sdk::authentication::{AuthenticationScopePolicy, ScopeRequirement};
use cloud_sdk::transport::{EndpointIdentity, EndpointScheme};
use std::net::IpAddr;

use super::{BasicCredentialScope, BearerCredentialScope, CredentialScopeView};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum AuthenticationValidationError {
    InsecureEndpoint,
    EndpointMismatch,
    IncompletePolicy,
    ScopeRejected,
}

pub(crate) fn map_authentication_error(
    error: AuthenticationValidationError,
) -> super::TransportError {
    match error {
        AuthenticationValidationError::InsecureEndpoint => {
            super::TransportError::InsecureAuthenticationEndpoint
        }
        AuthenticationValidationError::EndpointMismatch => {
            super::TransportError::AuthenticationEndpointMismatch
        }
        AuthenticationValidationError::IncompletePolicy
        | AuthenticationValidationError::ScopeRejected => {
            super::TransportError::AuthenticationScopeRejected
        }
    }
}

pub(crate) fn validate_bearer_authentication<'a>(
    endpoint: EndpointIdentity<'a>,
    scope: &'a BearerCredentialScope,
    policy: AuthenticationScopePolicy<'a>,
    allow_insecure_loopback: bool,
) -> Result<(), AuthenticationValidationError> {
    validate_authentication(endpoint, scope, policy, allow_insecure_loopback)
}

pub(crate) fn validate_basic_authentication<'a>(
    endpoint: EndpointIdentity<'a>,
    scope: &'a BasicCredentialScope,
    policy: AuthenticationScopePolicy<'a>,
    allow_insecure_loopback: bool,
) -> Result<(), AuthenticationValidationError> {
    validate_authentication(endpoint, scope, policy, allow_insecure_loopback)
}

fn validate_authentication<'a, S: CredentialScopeView>(
    endpoint: EndpointIdentity<'a>,
    scope: &'a S,
    policy: AuthenticationScopePolicy<'a>,
    allow_insecure_loopback: bool,
) -> Result<(), AuthenticationValidationError> {
    let secure_destination = endpoint.scheme() == EndpointScheme::Https;
    let admitted_test_loopback = cfg!(test)
        && allow_insecure_loopback
        && endpoint.scheme() == EndpointScheme::Http
        && is_numeric_loopback(endpoint.host());
    if !secure_destination && !admitted_test_loopback {
        return Err(AuthenticationValidationError::InsecureEndpoint);
    }
    if scope
        .endpoint_identity()
        .map_err(|_| AuthenticationValidationError::ScopeRejected)?
        != endpoint
    {
        return Err(AuthenticationValidationError::EndpointMismatch);
    }
    if !matches!(
        policy.provider_requirement(),
        ScopeRequirement::Required(expected) if expected == scope.provider()
    ) || !matches!(
        policy.service_requirement(),
        ScopeRequirement::Required(expected) if expected == scope.service()
    ) {
        return Err(AuthenticationValidationError::IncompletePolicy);
    }
    match policy.endpoint_requirement() {
        ScopeRequirement::Required(expected) if expected == endpoint => {}
        ScopeRequirement::Required(_) => {
            return Err(AuthenticationValidationError::EndpointMismatch);
        }
        ScopeRequirement::Optional(_) | ScopeRequirement::Forbidden => {
            return Err(AuthenticationValidationError::IncompletePolicy);
        }
    }
    let credential_scope = scope
        .borrowed()
        .map_err(|_| AuthenticationValidationError::ScopeRejected)?;
    if admitted_test_loopback {
        return validate_test_loopback_scope(endpoint, scope, policy);
    }
    policy
        .validate(credential_scope)
        .map_err(|_| AuthenticationValidationError::ScopeRejected)
}

fn validate_test_loopback_scope<'a, S: CredentialScopeView>(
    endpoint: EndpointIdentity<'a>,
    scope: &'a S,
    policy: AuthenticationScopePolicy<'a>,
) -> Result<(), AuthenticationValidationError> {
    let projected_endpoint = EndpointIdentity::new(
        EndpointScheme::Https,
        endpoint.host(),
        endpoint.effective_port(),
        endpoint.base_path(),
    )
    .map_err(|_| AuthenticationValidationError::ScopeRejected)?;
    let projected_policy = AuthenticationScopePolicy::new(
        ScopeRequirement::Required(scope.provider()),
        ScopeRequirement::Required(scope.service()),
        ScopeRequirement::Required(projected_endpoint),
        policy.audience_requirement(),
        policy.account_requirement(),
        policy.tenant_requirement(),
    );
    let projected_scope = scope
        .borrowed_with_endpoint(projected_endpoint)
        .map_err(|_| AuthenticationValidationError::ScopeRejected)?;
    projected_policy
        .validate(projected_scope)
        .map_err(|_| AuthenticationValidationError::ScopeRejected)
}

fn is_numeric_loopback(host: &str) -> bool {
    let address = host
        .strip_prefix('[')
        .and_then(|value| value.strip_suffix(']'))
        .unwrap_or(host);
    address.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
}

#[cfg(test)]
mod tests {
    use cloud_sdk::authentication::{AuthenticationScopePolicy, ScopeRequirement, ScopeValue};
    use cloud_sdk::transport::CustomEndpointAcknowledgement;
    use cloud_sdk::{ProviderId, ServiceId};

    use super::{
        AuthenticationValidationError, validate_basic_authentication,
        validate_bearer_authentication,
    };
    use crate::shared::{BasicCredentialScope, BearerCredentialScope, HttpsEndpoint};

    fn endpoint(value: &str) -> HttpsEndpoint {
        HttpsEndpoint::new_custom(
            value,
            CustomEndpointAcknowledgement::trusted_operator_configuration(),
        )
        .unwrap_or_else(|_| unreachable!())
    }

    fn provider() -> ProviderId {
        ProviderId::new("example").unwrap_or_else(|_| unreachable!())
    }

    fn service() -> ServiceId {
        ServiceId::new("compute").unwrap_or_else(|_| unreachable!())
    }

    fn policy<'a>(
        endpoint: cloud_sdk::transport::EndpointIdentity<'a>,
    ) -> AuthenticationScopePolicy<'a> {
        AuthenticationScopePolicy::new(
            ScopeRequirement::Required(provider()),
            ScopeRequirement::Required(service()),
            ScopeRequirement::Required(endpoint),
            ScopeRequirement::Forbidden,
            ScopeRequirement::Forbidden,
            ScopeRequirement::Forbidden,
        )
    }

    fn value(text: &'static str) -> ScopeValue<'static> {
        ScopeValue::new(text).unwrap_or_else(|_| unreachable!())
    }

    #[test]
    fn exact_https_endpoint_provider_and_service_scope_is_admitted() {
        let configured = endpoint("https://api.example.test/v1");
        let credential_scope =
            BearerCredentialScope::new(provider(), service(), configured.clone());
        let identity = configured.identity().unwrap_or_else(|_| unreachable!());
        assert_eq!(
            validate_bearer_authentication(identity, &credential_scope, policy(identity), false,),
            Ok(())
        );
    }

    #[test]
    fn basic_uses_the_same_complete_extended_scope_policy() {
        let configured = endpoint("https://robot.example.test");
        let identity = configured.identity().unwrap_or_else(|_| unreachable!());
        let populated = BasicCredentialScope::new(provider(), service(), configured.clone())
            .try_with_audience("actual-audience")
            .and_then(|scope| scope.try_with_account("actual-account"))
            .and_then(|scope| scope.try_with_tenant("actual-tenant"))
            .unwrap_or_else(|_| unreachable!());
        let accepted = AuthenticationScopePolicy::new(
            ScopeRequirement::Required(provider()),
            ScopeRequirement::Required(service()),
            ScopeRequirement::Required(identity),
            ScopeRequirement::Required(value("actual-audience")),
            ScopeRequirement::Optional(value("actual-account")),
            ScopeRequirement::Required(value("actual-tenant")),
        );
        assert_eq!(
            validate_basic_authentication(identity, &populated, accepted, false),
            Ok(())
        );

        let rejected = [
            AuthenticationScopePolicy::new(
                ScopeRequirement::Required(provider()),
                ScopeRequirement::Required(service()),
                ScopeRequirement::Required(identity),
                ScopeRequirement::Required(value("other-audience")),
                ScopeRequirement::Optional(value("actual-account")),
                ScopeRequirement::Required(value("actual-tenant")),
            ),
            AuthenticationScopePolicy::new(
                ScopeRequirement::Required(provider()),
                ScopeRequirement::Required(service()),
                ScopeRequirement::Required(identity),
                ScopeRequirement::Optional(value("actual-audience")),
                ScopeRequirement::Forbidden,
                ScopeRequirement::Optional(value("actual-tenant")),
            ),
        ];
        for policy in rejected {
            assert_eq!(
                validate_basic_authentication(identity, &populated, policy, false),
                Err(AuthenticationValidationError::ScopeRejected)
            );
        }
    }

    #[test]
    fn configured_and_credential_endpoint_mismatches_fail_closed() {
        let configured = endpoint("https://api.example.test/v1");
        let other = endpoint("https://other.example.test/v1");
        let identity = configured.identity().unwrap_or_else(|_| unreachable!());
        let other_identity = other.identity().unwrap_or_else(|_| unreachable!());
        let correct_scope = BearerCredentialScope::new(provider(), service(), configured.clone());
        assert_eq!(
            validate_bearer_authentication(identity, &correct_scope, policy(other_identity), false,),
            Err(AuthenticationValidationError::EndpointMismatch)
        );

        let wrong_scope = BearerCredentialScope::new(provider(), service(), other);
        assert_eq!(
            validate_bearer_authentication(identity, &wrong_scope, policy(identity), false,),
            Err(AuthenticationValidationError::EndpointMismatch)
        );
    }

    #[test]
    fn optional_or_forbidden_base_identity_rules_cannot_downgrade_binding() {
        let configured = endpoint("https://api.example.test/v1");
        let identity = configured.identity().unwrap_or_else(|_| unreachable!());
        let credential = BearerCredentialScope::new(provider(), service(), configured.clone());
        let policies = [
            AuthenticationScopePolicy::new(
                ScopeRequirement::Optional(provider()),
                ScopeRequirement::Required(service()),
                ScopeRequirement::Required(identity),
                ScopeRequirement::Forbidden,
                ScopeRequirement::Forbidden,
                ScopeRequirement::Forbidden,
            ),
            AuthenticationScopePolicy::new(
                ScopeRequirement::Required(provider()),
                ScopeRequirement::Forbidden,
                ScopeRequirement::Required(identity),
                ScopeRequirement::Forbidden,
                ScopeRequirement::Forbidden,
                ScopeRequirement::Forbidden,
            ),
            AuthenticationScopePolicy::new(
                ScopeRequirement::Required(provider()),
                ScopeRequirement::Required(service()),
                ScopeRequirement::Optional(identity),
                ScopeRequirement::Forbidden,
                ScopeRequirement::Forbidden,
                ScopeRequirement::Forbidden,
            ),
        ];
        for downgraded in policies {
            assert_eq!(
                validate_bearer_authentication(identity, &credential, downgraded, false),
                Err(AuthenticationValidationError::IncompletePolicy)
            );
        }
    }

    #[test]
    fn production_validation_rejects_plain_http_with_complete_scope() {
        let configured = HttpsEndpoint::local_http("http://127.0.0.1:3000/v1")
            .unwrap_or_else(|_| unreachable!());
        let identity = configured.identity().unwrap_or_else(|_| unreachable!());
        let credential = BearerCredentialScope::new(provider(), service(), configured.clone());
        assert_eq!(
            validate_bearer_authentication(identity, &credential, policy(identity), false,),
            Err(AuthenticationValidationError::InsecureEndpoint)
        );
    }

    #[test]
    fn test_exception_admits_only_numeric_http_loopback_destinations() {
        for value in ["http://127.0.0.1:3000/v1", "http://[::1]:3000/v1"] {
            let configured = HttpsEndpoint::local_http(value).unwrap_or_else(|_| unreachable!());
            let identity = configured.identity().unwrap_or_else(|_| unreachable!());
            let credential = BearerCredentialScope::new(provider(), service(), configured.clone());
            assert_eq!(
                validate_bearer_authentication(identity, &credential, policy(identity), true,),
                Ok(())
            );
        }

        let configured = HttpsEndpoint::local_http("http://127.0.0.1:3000/v1")
            .unwrap_or_else(|_| unreachable!());
        let credential = BearerCredentialScope::new(provider(), service(), configured);
        for host in ["192.0.2.1", "localhost", "api.example.test"] {
            let identity = cloud_sdk::transport::EndpointIdentity::new(
                cloud_sdk::transport::EndpointScheme::Http,
                host,
                3000,
                "/v1",
            )
            .unwrap_or_else(|_| unreachable!());
            assert_eq!(
                validate_bearer_authentication(identity, &credential, policy(identity), true,),
                Err(AuthenticationValidationError::InsecureEndpoint)
            );
        }
    }

    #[test]
    fn test_loopback_exception_still_validates_every_extended_scope_field() {
        let configured = HttpsEndpoint::local_http("http://127.0.0.1:3000/v1")
            .unwrap_or_else(|_| unreachable!());
        let identity = configured.identity().unwrap_or_else(|_| unreachable!());
        let populated = BearerCredentialScope::new(provider(), service(), configured.clone())
            .try_with_audience("actual-audience")
            .and_then(|scope| scope.try_with_account("actual-account"))
            .and_then(|scope| scope.try_with_tenant("actual-tenant"))
            .unwrap_or_else(|_| unreachable!());
        let rejected = [
            AuthenticationScopePolicy::new(
                ScopeRequirement::Required(provider()),
                ScopeRequirement::Required(service()),
                ScopeRequirement::Required(identity),
                ScopeRequirement::Required(value("other-audience")),
                ScopeRequirement::Optional(value("actual-account")),
                ScopeRequirement::Optional(value("actual-tenant")),
            ),
            AuthenticationScopePolicy::new(
                ScopeRequirement::Required(provider()),
                ScopeRequirement::Required(service()),
                ScopeRequirement::Required(identity),
                ScopeRequirement::Optional(value("actual-audience")),
                ScopeRequirement::Forbidden,
                ScopeRequirement::Optional(value("actual-tenant")),
            ),
            AuthenticationScopePolicy::new(
                ScopeRequirement::Required(provider()),
                ScopeRequirement::Required(service()),
                ScopeRequirement::Required(identity),
                ScopeRequirement::Optional(value("actual-audience")),
                ScopeRequirement::Optional(value("actual-account")),
                ScopeRequirement::Required(value("other-tenant")),
            ),
        ];
        for policy in rejected {
            assert_eq!(
                validate_bearer_authentication(identity, &populated, policy, true),
                Err(AuthenticationValidationError::ScopeRejected)
            );
        }

        let missing = BearerCredentialScope::new(provider(), service(), configured.clone());
        let required = AuthenticationScopePolicy::new(
            ScopeRequirement::Required(provider()),
            ScopeRequirement::Required(service()),
            ScopeRequirement::Required(identity),
            ScopeRequirement::Required(value("audience")),
            ScopeRequirement::Required(value("account")),
            ScopeRequirement::Required(value("tenant")),
        );
        assert_eq!(
            validate_bearer_authentication(identity, &missing, required, true),
            Err(AuthenticationValidationError::ScopeRejected)
        );
    }
}