coil-auth 0.1.1

Authorisation models and auth package support for the Coil framework.
Documentation
use super::{LiveAuthError, LiveAuthExplainRequest};
use crate::{AuthModelPackageSelection, CapabilityExplanation, CoilAuth};
use coil_config::PlatformConfig;
use coil_data::DataRuntime;
use std::fmt;
use std::sync::OnceLock;

#[cfg(test)]
use crate::{Capability, DefaultAuthModelPackage, DefaultSubject, Entity, ExplainOptions};

pub struct LiveAuthExplainHost {
    data: DataRuntime,
    tenant_id: i64,
    auth_package: AuthModelPackageSelection,
    explainer: OnceLock<Result<PostgresAuthExplainer, String>>,
}

impl fmt::Debug for LiveAuthExplainHost {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("LiveAuthExplainHost")
            .field("tenant_id", &self.tenant_id)
            .field("auth_package", &self.auth_package.manifest().name)
            .finish_non_exhaustive()
    }
}

impl LiveAuthExplainHost {
    pub fn from_config(
        config: &PlatformConfig,
        auth_package: AuthModelPackageSelection,
    ) -> Result<Self, LiveAuthError> {
        if !config.auth.explain_api {
            return Err(LiveAuthError::ExplainApiDisabled);
        }

        let data = DataRuntime::from_config(&config.database).map_err(|error| {
            LiveAuthError::BackendInitialization {
                reason: error.to_string(),
            }
        })?;

        Self::from_runtime(config, data, auth_package)
    }

    pub fn from_runtime(
        config: &PlatformConfig,
        data: DataRuntime,
        auth_package: AuthModelPackageSelection,
    ) -> Result<Self, LiveAuthError> {
        if !config.auth.explain_api {
            return Err(LiveAuthError::ExplainApiDisabled);
        }

        Ok(Self {
            data,
            tenant_id: config.auth.tenant_id,
            auth_package,
            explainer: OnceLock::new(),
        })
    }

    fn explainer(&self) -> Result<&PostgresAuthExplainer, LiveAuthError> {
        match self.explainer.get_or_init(|| self.build_explainer()) {
            Ok(explainer) => Ok(explainer),
            Err(reason) => Err(LiveAuthError::BackendInitialization {
                reason: reason.clone(),
            }),
        }
    }

    fn build_explainer(&self) -> Result<PostgresAuthExplainer, String> {
        let client = self
            .data
            .clone()
            .connect_lazy_postgres()
            .map_err(|error| error.to_string())?;
        let engine = zanzibar::postgres::PostgresRebacEngine::new(client.pool.clone());

        Ok(PostgresAuthExplainer {
            auth: CoilAuth::new(engine, self.tenant_id),
            package: self.auth_package.clone(),
        })
    }

    pub async fn explain_capability(
        &self,
        request: &LiveAuthExplainRequest,
    ) -> Result<CapabilityExplanation, LiveAuthError> {
        self.explainer()?.explain_capability(request).await
    }
}

#[derive(Clone)]
struct PostgresAuthExplainer {
    auth: CoilAuth<zanzibar::postgres::PostgresRebacEngine>,
    package: AuthModelPackageSelection,
}

impl PostgresAuthExplainer {
    async fn explain_capability(
        &self,
        request: &LiveAuthExplainRequest,
    ) -> Result<CapabilityExplanation, LiveAuthError> {
        self.auth
            .explain_capability_with_options(
                self.package.package(),
                &request.subject,
                request.capability,
                &request.object,
                request.options,
            )
            .await
            .map_err(|error| LiveAuthError::Explain {
                reason: error.to_string(),
            })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{AuthModelPackageSelection, configured_auth_model_package};
    use coil_config::PlatformConfig;
    use std::future::Future;
    use std::pin::Pin;
    use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};

    const BASE_CONFIG: &str = r#"
[app]
name = "showcase-events"
environment = "production"

[server]
bind = "0.0.0.0:8080"
trusted_proxies = []

[http.session]
store = "memory"
idle_timeout_secs = 3600
absolute_timeout_secs = 86400

[http.session_cookie]
name = "coil_session"
path = "/"
same_site = "lax"
secure = true
http_only = true

[http.flash_cookie]
name = "coil_flash"
path = "/"
same_site = "lax"
secure = true
http_only = true

[http.csrf]
enabled = true
field_name = "_csrf"
header_name = "x-csrf-token"

[tls]
mode = "external"

[storage]
default_class = "public_upload"
deployment = "single_node"
single_node_escape_hatch = "explicit_single_node"
local_root = "/tmp/coil-auth-live"

[cache]
l1 = "moka"

[i18n]
default_locale = "en"
supported_locales = ["en"]
fallback_locale = "en"
localized_routes = false

[seo]
canonical_host = "example.com"
emit_json_ld = true
sitemap_enabled = true

[auth]
package = "coil-default-auth"
explain_api = true
tenant_id = 42

[modules]
enabled = ["cms"]

[wasm]
directory = "wasm"
default_time_limit_ms = 1000
allow_network = false

[jobs]
backend = "redis"

[observability]
metrics = false
tracing = false

[assets]
publish_manifest = false
"#;

    fn explain_request() -> LiveAuthExplainRequest {
        LiveAuthExplainRequest {
            subject: DefaultSubject::entity(Entity::user("alice")),
            capability: Capability::CmsPageRead,
            object: Entity::page("homepage"),
            options: ExplainOptions::default(),
        }
    }

    fn config(explain_api: bool) -> PlatformConfig {
        let mut config = PlatformConfig::from_toml_str(BASE_CONFIG).unwrap();
        config.auth.explain_api = explain_api;
        config.database.url = None;
        config
    }

    fn block_on<F: Future>(future: F) -> F::Output {
        fn raw_waker() -> RawWaker {
            fn clone(_: *const ()) -> RawWaker {
                raw_waker()
            }
            fn wake(_: *const ()) {}
            fn wake_by_ref(_: *const ()) {}
            fn drop(_: *const ()) {}
            RawWaker::new(
                std::ptr::null(),
                &RawWakerVTable::new(clone, wake, wake_by_ref, drop),
            )
        }

        let waker = unsafe { Waker::from_raw(raw_waker()) };
        let mut future = Box::pin(future);
        let mut context = Context::from_waker(&waker);

        loop {
            match Future::poll(Pin::as_mut(&mut future), &mut context) {
                Poll::Ready(output) => return output,
                Poll::Pending => std::thread::yield_now(),
            }
        }
    }

    #[test]
    fn from_runtime_rejects_disabled_deployment_config() {
        let package = AuthModelPackageSelection::new(DefaultAuthModelPackage::default());
        let error = LiveAuthExplainHost::from_runtime(
            &config(false),
            DataRuntime::from_config(&config(false).database).unwrap(),
            package,
        )
        .unwrap_err();

        assert_eq!(error, LiveAuthError::ExplainApiDisabled);
    }

    #[test]
    fn explain_capability_uses_the_live_postgres_path() {
        let package = AuthModelPackageSelection::new(DefaultAuthModelPackage::default());
        let host = LiveAuthExplainHost::from_runtime(
            &config(true),
            DataRuntime::from_config(&config(true).database).unwrap(),
            package,
        )
        .unwrap();

        let error = block_on(host.explain_capability(&explain_request())).unwrap_err();

        assert!(matches!(error, LiveAuthError::BackendInitialization { .. }));
    }

    #[test]
    fn from_runtime_keeps_the_configured_package_identity() {
        let package =
            AuthModelPackageSelection::new(configured_auth_model_package("coil-extended-auth"));
        let host = LiveAuthExplainHost::from_runtime(
            &config(true),
            DataRuntime::from_config(&config(true).database).unwrap(),
            package,
        )
        .unwrap();

        assert!(format!("{host:?}").contains("coil-extended-auth"));
    }
}