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}