cedarling 0.0.65

The Cedarling: a high-performance local authorization service powered by the Rust Cedar Engine.
Documentation
// This software is available under the Apache-2.0 license.
// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text.
//
// Copyright (c) 2024, Gluu, Inc.

//! This module is responsible for getting lock config from the
//! `.well-known/lock-server-configuration` endpoint.

use std::str::FromStr;

use crate::http::{HttpClient, InitializeHttpClientError};
use crate::http_utils::HttpRequestError;
use serde::{Deserialize, Deserializer, de};

#[derive(Debug, Deserialize, PartialEq, Clone)]
pub(super) struct LockConfig {
    pub version: String,
    #[serde(rename = "issuer", deserialize_with = "host_to_oidc")]
    pub issuer_oidc_url: Url,
    #[serde(rename = "audit", default)]
    pub audit_endpoints: AuditEndpoints,
    #[serde(rename = "config", default)]
    pub config_endpoints: ConfigEndpoints,
}

#[derive(Debug, Deserialize, PartialEq, Clone, Default)]
pub(super) struct AuditEndpoints {
    #[serde(
        rename = "log_endpoint",
        deserialize_with = "deserialize_to_bulk_endpoint",
        default
    )]
    pub log: Option<Url>,
    #[serde(
        rename = "health_endpoint",
        deserialize_with = "deserialize_to_bulk_endpoint",
        default
    )]
    pub health: Option<Url>,
    #[serde(
        rename = "telemetry_endpoint",
        deserialize_with = "deserialize_to_bulk_endpoint",
        default
    )]
    pub telemetry: Option<Url>,
}

#[derive(Debug, Deserialize, PartialEq, Clone, Default)]
pub(super) struct ConfigEndpoints {
    #[serde(rename = "config_endpoint", default)]
    pub config: Option<Url>,
    #[serde(rename = "issuers_endpoint", default)]
    pub issuers: Option<Url>,
    #[serde(rename = "policy_endpoint", default)]
    pub policy: Option<Url>,
    #[serde(rename = "schema_endpoint", default)]
    pub schema: Option<Url>,
    #[serde(rename = "sse_endpoint", default)]
    pub sse: Option<Url>,
}

#[derive(Debug, thiserror::Error)]
pub enum GetLockConfigError {
    #[error(transparent)]
    InitializeHttpClient(#[from] InitializeHttpClientError),
    #[error(transparent)]
    Request(#[from] HttpRequestError),
}

impl LockConfig {
    pub(super) async fn get(
        lock_config_url: &url::Url,
        http_client: &HttpClient,
    ) -> Result<Self, GetLockConfigError> {
        let config: LockConfig = http_client.get_json(lock_config_url.as_ref()).await?;
        Ok(config)
    }
}

/// A wrapper for [`url::Url`] that implements [`serde::de::Deserialize`].
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(super) struct Url(pub(super) url::Url);

impl AsRef<url::Url> for Url {
    fn as_ref(&self) -> &url::Url {
        &self.0
    }
}

impl AsRef<str> for Url {
    fn as_ref(&self) -> &str {
        self.0.as_str()
    }
}

impl FromStr for Url {
    type Err = url::ParseError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let inner: url::Url = s.parse()?;
        Ok(Self(inner))
    }
}

impl<'de> Deserialize<'de> for Url {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let src: String = String::deserialize(deserializer)?;
        let url = src
            .parse()
            .map_err(|e| de::Error::custom(format!("error while parsing `{src}` as a URL: {e}")))?;

        Ok(url)
    }
}

/// Deserialize an audit endpoint like `/api/v1/audit/log` to `/apit/v1/audit/log/bulk`.
fn deserialize_to_bulk_endpoint<'de, D>(deserializer: D) -> Result<Option<Url>, D::Error>
where
    D: Deserializer<'de>,
{
    let src: String = String::deserialize(deserializer)?;

    if src.is_empty() {
        return Ok(None);
    }

    // We need to append the `/bulk` manually since the response from the
    // `.well-known/lock-server-configuration` endpoint will not have the `/bulk`.
    let src = src + "/bulk";

    let url: url::Url = src.parse().map_err(|e| {
        de::Error::custom(format!("the bulk endpoint url, '{src}', is not valid: {e}"))
    })?;

    Ok(Some(Url(url)))
}

/// Deserialize the host, `demoexample.jans.io`, to the openid configuration endpoint:
/// `https://demoexample.jans.io/.well-known/openid-configuration`.
fn host_to_oidc<'de, D>(deserializer: D) -> Result<Url, D::Error>
where
    D: Deserializer<'de>,
{
    let host: String = String::deserialize(deserializer)?;

    // NOTE: for tests, we can't use http since mockito doesn't support creating https
    // endpoints. However in prod, this should always be https.
    #[cfg(not(test))]
    let derived_url = format!("https://{host}/.well-known/openid-configuration");
    #[cfg(test)]
    let derived_url = format!("http://{host}/.well-known/openid-configuration");

    let url: url::Url = derived_url.parse().map_err(|e| {
        de::Error::custom(format!(
            "the derived url, '{derived_url}', from the host, '{host}' is not valid: {e}"
        ))
    })?;

    Ok(Url(url))
}

#[cfg(test)]
mod test {
    use super::super::lock_config::{AuditEndpoints, ConfigEndpoints};
    use super::LockConfig;
    use serde_json::json;
    use test_utils::assert_eq;

    #[tokio::test]
    async fn should_deserialize_lock_config() {
        let src = json!({
            "version": "1.0",
            "issuer": "test.com",
            "audit": {
                "log_endpoint": "https://test.com/audit/log",
                "health_endpoint": "",
            },
        });

        let deserialized =
            serde_json::from_value::<LockConfig>(src).expect("deserialize audit endpoints");

        assert_eq!(
            deserialized,
            LockConfig {
                version: "1.0".into(),
                // NOTE: resolving this url in tests will always be `http` instead of `https`
                // to support mocking using mockito
                issuer_oidc_url: "http://test.com/.well-known/openid-configuration"
                    .parse()
                    .unwrap(),
                audit_endpoints: AuditEndpoints {
                    // should resolve to the `/bulk` endpoint automatically
                    log: Some("https://test.com/audit/log/bulk".parse().unwrap()),
                    health: None,
                    telemetry: None
                },
                config_endpoints: ConfigEndpoints::default(),
            }
        );
    }
}