Skip to main content

coil_auth/live/
host.rs

1use super::{LiveAuthError, LiveAuthExplainRequest};
2use crate::{AuthModelPackageSelection, CapabilityExplanation, CoilAuth};
3use coil_config::PlatformConfig;
4use coil_data::DataRuntime;
5use std::fmt;
6use std::sync::OnceLock;
7
8#[cfg(test)]
9use crate::{Capability, DefaultAuthModelPackage, DefaultSubject, Entity, ExplainOptions};
10
11pub struct LiveAuthExplainHost {
12    data: DataRuntime,
13    tenant_id: i64,
14    auth_package: AuthModelPackageSelection,
15    explainer: OnceLock<Result<PostgresAuthExplainer, String>>,
16}
17
18impl fmt::Debug for LiveAuthExplainHost {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        f.debug_struct("LiveAuthExplainHost")
21            .field("tenant_id", &self.tenant_id)
22            .field("auth_package", &self.auth_package.manifest().name)
23            .finish_non_exhaustive()
24    }
25}
26
27impl LiveAuthExplainHost {
28    pub fn from_config(
29        config: &PlatformConfig,
30        auth_package: AuthModelPackageSelection,
31    ) -> Result<Self, LiveAuthError> {
32        if !config.auth.explain_api {
33            return Err(LiveAuthError::ExplainApiDisabled);
34        }
35
36        let data = DataRuntime::from_config(&config.database).map_err(|error| {
37            LiveAuthError::BackendInitialization {
38                reason: error.to_string(),
39            }
40        })?;
41
42        Self::from_runtime(config, data, auth_package)
43    }
44
45    pub fn from_runtime(
46        config: &PlatformConfig,
47        data: DataRuntime,
48        auth_package: AuthModelPackageSelection,
49    ) -> Result<Self, LiveAuthError> {
50        if !config.auth.explain_api {
51            return Err(LiveAuthError::ExplainApiDisabled);
52        }
53
54        Ok(Self {
55            data,
56            tenant_id: config.auth.tenant_id,
57            auth_package,
58            explainer: OnceLock::new(),
59        })
60    }
61
62    fn explainer(&self) -> Result<&PostgresAuthExplainer, LiveAuthError> {
63        match self.explainer.get_or_init(|| self.build_explainer()) {
64            Ok(explainer) => Ok(explainer),
65            Err(reason) => Err(LiveAuthError::BackendInitialization {
66                reason: reason.clone(),
67            }),
68        }
69    }
70
71    fn build_explainer(&self) -> Result<PostgresAuthExplainer, String> {
72        let client = self
73            .data
74            .clone()
75            .connect_lazy_postgres()
76            .map_err(|error| error.to_string())?;
77        let engine = zanzibar::postgres::PostgresRebacEngine::new(client.pool.clone());
78
79        Ok(PostgresAuthExplainer {
80            auth: CoilAuth::new(engine, self.tenant_id),
81            package: self.auth_package.clone(),
82        })
83    }
84
85    pub async fn explain_capability(
86        &self,
87        request: &LiveAuthExplainRequest,
88    ) -> Result<CapabilityExplanation, LiveAuthError> {
89        self.explainer()?.explain_capability(request).await
90    }
91}
92
93#[derive(Clone)]
94struct PostgresAuthExplainer {
95    auth: CoilAuth<zanzibar::postgres::PostgresRebacEngine>,
96    package: AuthModelPackageSelection,
97}
98
99impl PostgresAuthExplainer {
100    async fn explain_capability(
101        &self,
102        request: &LiveAuthExplainRequest,
103    ) -> Result<CapabilityExplanation, LiveAuthError> {
104        self.auth
105            .explain_capability_with_options(
106                self.package.package(),
107                &request.subject,
108                request.capability,
109                &request.object,
110                request.options,
111            )
112            .await
113            .map_err(|error| LiveAuthError::Explain {
114                reason: error.to_string(),
115            })
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::{AuthModelPackageSelection, configured_auth_model_package};
123    use coil_config::PlatformConfig;
124    use std::future::Future;
125    use std::pin::Pin;
126    use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
127
128    const BASE_CONFIG: &str = r#"
129[app]
130name = "showcase-events"
131environment = "production"
132
133[server]
134bind = "0.0.0.0:8080"
135trusted_proxies = []
136
137[http.session]
138store = "memory"
139idle_timeout_secs = 3600
140absolute_timeout_secs = 86400
141
142[http.session_cookie]
143name = "coil_session"
144path = "/"
145same_site = "lax"
146secure = true
147http_only = true
148
149[http.flash_cookie]
150name = "coil_flash"
151path = "/"
152same_site = "lax"
153secure = true
154http_only = true
155
156[http.csrf]
157enabled = true
158field_name = "_csrf"
159header_name = "x-csrf-token"
160
161[tls]
162mode = "external"
163
164[storage]
165default_class = "public_upload"
166deployment = "single_node"
167single_node_escape_hatch = "explicit_single_node"
168local_root = "/tmp/coil-auth-live"
169
170[cache]
171l1 = "moka"
172
173[i18n]
174default_locale = "en"
175supported_locales = ["en"]
176fallback_locale = "en"
177localized_routes = false
178
179[seo]
180canonical_host = "example.com"
181emit_json_ld = true
182sitemap_enabled = true
183
184[auth]
185package = "coil-default-auth"
186explain_api = true
187tenant_id = 42
188
189[modules]
190enabled = ["cms"]
191
192[wasm]
193directory = "wasm"
194default_time_limit_ms = 1000
195allow_network = false
196
197[jobs]
198backend = "redis"
199
200[observability]
201metrics = false
202tracing = false
203
204[assets]
205publish_manifest = false
206"#;
207
208    fn explain_request() -> LiveAuthExplainRequest {
209        LiveAuthExplainRequest {
210            subject: DefaultSubject::entity(Entity::user("alice")),
211            capability: Capability::CmsPageRead,
212            object: Entity::page("homepage"),
213            options: ExplainOptions::default(),
214        }
215    }
216
217    fn config(explain_api: bool) -> PlatformConfig {
218        let mut config = PlatformConfig::from_toml_str(BASE_CONFIG).unwrap();
219        config.auth.explain_api = explain_api;
220        config.database.url = None;
221        config
222    }
223
224    fn block_on<F: Future>(future: F) -> F::Output {
225        fn raw_waker() -> RawWaker {
226            fn clone(_: *const ()) -> RawWaker {
227                raw_waker()
228            }
229            fn wake(_: *const ()) {}
230            fn wake_by_ref(_: *const ()) {}
231            fn drop(_: *const ()) {}
232            RawWaker::new(
233                std::ptr::null(),
234                &RawWakerVTable::new(clone, wake, wake_by_ref, drop),
235            )
236        }
237
238        let waker = unsafe { Waker::from_raw(raw_waker()) };
239        let mut future = Box::pin(future);
240        let mut context = Context::from_waker(&waker);
241
242        loop {
243            match Future::poll(Pin::as_mut(&mut future), &mut context) {
244                Poll::Ready(output) => return output,
245                Poll::Pending => std::thread::yield_now(),
246            }
247        }
248    }
249
250    #[test]
251    fn from_runtime_rejects_disabled_deployment_config() {
252        let package = AuthModelPackageSelection::new(DefaultAuthModelPackage::default());
253        let error = LiveAuthExplainHost::from_runtime(
254            &config(false),
255            DataRuntime::from_config(&config(false).database).unwrap(),
256            package,
257        )
258        .unwrap_err();
259
260        assert_eq!(error, LiveAuthError::ExplainApiDisabled);
261    }
262
263    #[test]
264    fn explain_capability_uses_the_live_postgres_path() {
265        let package = AuthModelPackageSelection::new(DefaultAuthModelPackage::default());
266        let host = LiveAuthExplainHost::from_runtime(
267            &config(true),
268            DataRuntime::from_config(&config(true).database).unwrap(),
269            package,
270        )
271        .unwrap();
272
273        let error = block_on(host.explain_capability(&explain_request())).unwrap_err();
274
275        assert!(matches!(error, LiveAuthError::BackendInitialization { .. }));
276    }
277
278    #[test]
279    fn from_runtime_keeps_the_configured_package_identity() {
280        let package =
281            AuthModelPackageSelection::new(configured_auth_model_package("coil-extended-auth"));
282        let host = LiveAuthExplainHost::from_runtime(
283            &config(true),
284            DataRuntime::from_config(&config(true).database).unwrap(),
285            package,
286        )
287        .unwrap();
288
289        assert!(format!("{host:?}").contains("coil-extended-auth"));
290    }
291}