Skip to main content

allowthem_saas/
dashboard.rs

1//! Dashboard auth dogfooding primitives.
2//!
3//! The dashboard is *not* a tenant — it is the SaaS control plane's own UI,
4//! backed by a dedicated `dashboard.db` and using the same allowthem auth
5//! surface as everything else. This module exposes the cookie-name helper,
6//! the [`DashboardState`] handle bundle, and the cross-DB signup primitive
7//! ([`dashboard_signup`]) that the dashboard's HTTP handlers (in 99c.2)
8//! call. The HTTP handlers stay thin glue; the cross-DB chain lives here.
9
10use std::path::PathBuf;
11use std::sync::Arc;
12
13use allowthem_core::error::AuthError;
14use allowthem_core::types::UserId;
15use allowthem_core::{AllowThem, Email, User};
16
17use crate::cache::HandleCache;
18use crate::control_db::ControlDb;
19use crate::error::SaasError;
20use crate::tenants::{ProvisionResult, TenantBuilderConfig};
21
22/// Cookie name for dashboard sessions.
23///
24/// Production uses the `__Host-` prefix, which forbids `Domain` and requires
25/// `Secure` — both already true under SaaS deployment via Caddy (parent spec
26/// §6.2). Dev keeps the plain name so HTTP localhost workflows work.
27pub fn dashboard_cookie_name(is_production: bool) -> &'static str {
28    if is_production {
29        "__Host-allowthem_dashboard_session"
30    } else {
31        "allowthem_dashboard_session"
32    }
33}
34
35/// Bundle of references the dashboard handlers need at runtime.
36///
37/// Built once at process startup (see `binaries/saas/main.rs`) and held in
38/// process state for the life of the program. Cheap to clone — every field
39/// is either an `Arc` or a clone-cheap moka cache handle.
40#[derive(Clone)]
41pub struct DashboardState {
42    /// The dashboard's own AllowThem handle, backed by `dashboard.db`.
43    pub ath: AllowThem,
44    /// Control-plane DB — needed to look up tenants, members, and provision.
45    pub control_db: Arc<ControlDb>,
46    /// Tenant data dir + builder config — needed to call `provision_tenant`.
47    pub tenant_data_dir: PathBuf,
48    pub tenant_config: Arc<TenantBuilderConfig>,
49    /// Shared with the tenant-router middleware so freshly provisioned
50    /// tenants land in the cache immediately.
51    pub handle_cache: HandleCache,
52    /// Mirrors `TenantBuilderConfig.is_production`. Convenience for handlers
53    /// that need to pick cookie names without dereferencing through `Arc`.
54    pub is_production: bool,
55}
56
57/// Inputs to [`dashboard_signup`].
58pub struct DashboardSignupParams {
59    pub email: String,
60    pub password: String,
61    pub tenant_name: String,
62    pub tenant_slug: String,
63}
64
65/// Outputs of a successful [`dashboard_signup`].
66pub struct DashboardSignupResult {
67    /// Newly created dashboard user (in `dashboard.db`).
68    pub user: User,
69    /// New tenant artifacts: `Tenant`, per-tenant `AllowThem`, default OIDC
70    /// application's client_id and one-time client_secret.
71    pub provision: ProvisionResult,
72    /// `Set-Cookie` header value for the new dashboard session.
73    pub set_cookie: String,
74}
75
76/// Errors discriminated by the signup HTTP handler.
77#[derive(Debug, thiserror::Error)]
78pub enum DashboardSignupError {
79    #[error("invalid email")]
80    InvalidEmail,
81    #[error("email already in use")]
82    EmailTaken,
83    #[error("provision failed: {0}")]
84    ProvisionFailed(SaasError),
85    #[error(transparent)]
86    Auth(AuthError),
87}
88
89/// Cross-DB signup chain.
90///
91/// 1. Create the dashboard user in `dashboard.db`. Surfaces duplicate-email
92///    *before* any tenant artifacts exist (most common failure mode).
93/// 2. Call `provision_tenant` — atomic in the control plane (inserts
94///    `tenants` + `tenant_members(owner)` in one txn, creates `<uuid>.db`,
95///    builds the per-tenant `AllowThem`, registers the default OIDC app).
96/// 3. On step-2 failure, compensate by deleting the dashboard user from
97///    step 1. Compensation failure is logged but does not mask the original
98///    error — the user can retry with a different email.
99/// 4. Mint a session cookie for the new dashboard user.
100pub async fn dashboard_signup(
101    state: &DashboardState,
102    params: DashboardSignupParams,
103) -> Result<DashboardSignupResult, DashboardSignupError> {
104    // Step 1: validate + create dashboard user.
105    let email = Email::new(params.email.clone()).map_err(|_| DashboardSignupError::InvalidEmail)?;
106
107    let user = state
108        .ath
109        .db()
110        .create_user(email.clone(), &params.password, None, None)
111        .await
112        .map_err(|e| match e {
113            AuthError::Conflict(ref msg) if msg.contains("email") => {
114                DashboardSignupError::EmailTaken
115            }
116            other => DashboardSignupError::Auth(other),
117        })?;
118
119    // Step 2: provision tenant + owner member (atomic in control plane).
120    let provision = match state
121        .control_db
122        .provision_tenant(
123            params.tenant_name,
124            params.tenant_slug,
125            email.as_str().to_owned(),
126            &state.tenant_data_dir,
127            &state.tenant_config,
128        )
129        .await
130    {
131        Ok(p) => p,
132        Err(e) => {
133            // Compensate: delete the dashboard user we created in step 1.
134            // Delete cascades sessions / user_roles / user_permissions via FK,
135            // so a retry with the same email works cleanly.
136            if let Err(del_err) = state.ath.db().delete_user(user.id).await {
137                tracing::error!(
138                    user_id = %user.id, error = %del_err,
139                    "dashboard_signup: rollback delete_user failed; orphan user"
140                );
141            }
142            return Err(DashboardSignupError::ProvisionFailed(e));
143        }
144    };
145
146    // Step 3: mint a session for the new user. AllowThem::create_session_cookie
147    // (crates/core/handle.rs:349) returns LoginOutcome { user, token, set_cookie }.
148    let outcome = state
149        .ath
150        .create_session_cookie(user.id)
151        .await
152        .map_err(DashboardSignupError::Auth)?;
153
154    Ok(DashboardSignupResult {
155        user: outcome.user,
156        provision,
157        set_cookie: outcome.set_cookie,
158    })
159}
160
161/// Errors for [`provision_tenant_for_user`].
162#[derive(Debug, thiserror::Error)]
163pub enum ProvisionForUserError {
164    #[error("dashboard user not found")]
165    UserNotFound,
166    #[error(transparent)]
167    Provision(#[from] SaasError),
168    #[error(transparent)]
169    Auth(#[from] AuthError),
170}
171
172/// Provision a tenant for an *already-existing* dashboard user.
173///
174/// Used when an authenticated dashboard user creates a second workspace, or
175/// after a previous signup partially failed. No compensation: if provisioning
176/// fails, the dashboard user is unchanged.
177pub async fn provision_tenant_for_user(
178    state: &DashboardState,
179    user_id: UserId,
180    tenant_name: String,
181    tenant_slug: String,
182) -> Result<ProvisionResult, ProvisionForUserError> {
183    let user = state
184        .ath
185        .db()
186        .get_user(user_id)
187        .await
188        .map_err(|e| match e {
189            AuthError::NotFound => ProvisionForUserError::UserNotFound,
190            other => ProvisionForUserError::Auth(other),
191        })?;
192
193    let result = state
194        .control_db
195        .provision_tenant(
196            tenant_name,
197            tenant_slug,
198            user.email.as_str().to_owned(),
199            &state.tenant_data_dir,
200            &state.tenant_config,
201        )
202        .await?;
203    Ok(result)
204}
205
206/// Result returned by [`dashboard_register_for_invite`].
207pub struct RegisterForInviteResult {
208    /// The newly-created dashboard user.
209    pub user: allowthem_core::User,
210    /// `Set-Cookie` header value for the new dashboard session.
211    pub set_cookie: String,
212}
213
214/// Create a dashboard user from an accepted invite token.
215///
216/// Like [`dashboard_signup`] but without tenant provisioning — the tenant
217/// already exists and the caller has already validated the invite token.
218/// The invite acceptance itself (clearing `invite_token_hash`) is handled
219/// separately by [`ControlDb::accept_invite`] after this call succeeds.
220///
221/// No compensation needed: the only side effect is dashboard user creation
222/// (a leaf op). If accept_invite fails after this succeeds, the caller is
223/// responsible for calling `state.ath.db().delete_user(user.id)`.
224pub async fn dashboard_register_for_invite(
225    state: &DashboardState,
226    email: String,
227    password: String,
228) -> Result<RegisterForInviteResult, DashboardSignupError> {
229    let email_obj = Email::new(email).map_err(|_| DashboardSignupError::InvalidEmail)?;
230    let user = state
231        .ath
232        .db()
233        .create_user(email_obj, &password, None, None)
234        .await
235        .map_err(|e| match e {
236            AuthError::Conflict(ref msg) if msg.contains("email") => {
237                DashboardSignupError::EmailTaken
238            }
239            other => DashboardSignupError::Auth(other),
240        })?;
241    let outcome = state
242        .ath
243        .create_session_cookie(user.id)
244        .await
245        .map_err(DashboardSignupError::Auth)?;
246    Ok(RegisterForInviteResult {
247        user: outcome.user,
248        set_cookie: outcome.set_cookie,
249    })
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use allowthem_core::AllowThemBuilder;
256    use sqlx::SqlitePool;
257
258    async fn test_dashboard_state() -> (DashboardState, tempfile::TempDir) {
259        let dir = tempfile::tempdir().expect("tempdir");
260
261        let dashboard_pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
262        let ath = AllowThemBuilder::with_pool(dashboard_pool)
263            .mfa_key([1u8; 32])
264            .signing_key([2u8; 32])
265            .csrf_key([3u8; 32])
266            .base_url("https://example.com")
267            .cookie_name(dashboard_cookie_name(false))
268            .cookie_secure(false)
269            .build()
270            .await
271            .unwrap();
272
273        let control_pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
274        let control_db = Arc::new(ControlDb::new(control_pool).await.unwrap());
275
276        let state = DashboardState {
277            ath,
278            control_db,
279            tenant_data_dir: dir.path().to_path_buf(),
280            tenant_config: Arc::new(TenantBuilderConfig {
281                mfa_key: [1u8; 32],
282                signing_key: [2u8; 32],
283                csrf_key: [3u8; 32],
284                base_domain: "example.com".into(),
285                is_production: false,
286                email_sender: None,
287                event_sink: None,
288                event_sink_factory: None,
289                mau_sink: None,
290                email_sender_factory: None,
291            }),
292            handle_cache: HandleCache::new(10),
293            is_production: false,
294        };
295        (state, dir)
296    }
297
298    #[test]
299    fn dashboard_cookie_name_prod_uses_host_prefix() {
300        assert_eq!(
301            dashboard_cookie_name(true),
302            "__Host-allowthem_dashboard_session"
303        );
304    }
305
306    #[test]
307    fn dashboard_cookie_name_dev_drops_prefix() {
308        assert_eq!(dashboard_cookie_name(false), "allowthem_dashboard_session");
309    }
310
311    #[tokio::test]
312    async fn signup_happy_path() {
313        let (state, _dir) = test_dashboard_state().await;
314        let result = dashboard_signup(
315            &state,
316            DashboardSignupParams {
317                email: "owner@acme.com".into(),
318                password: "supersecret".into(),
319                tenant_name: "Acme".into(),
320                tenant_slug: "acme".into(),
321            },
322        )
323        .await
324        .expect("signup");
325
326        // Dashboard user exists.
327        let email = Email::new("owner@acme.com".into()).unwrap();
328        let dashboard_user = state.ath.db().get_user_by_email(&email).await.unwrap();
329        assert_eq!(dashboard_user.id, result.user.id);
330
331        // Tenant + owner member exist.
332        let tenant = state
333            .control_db
334            .tenant_by_slug("acme")
335            .await
336            .unwrap()
337            .unwrap();
338        let (role, accepted_at): (String, Option<String>) = sqlx::query_as(
339            "SELECT role, accepted_at FROM tenant_members WHERE tenant_id = ?1 AND email = ?2",
340        )
341        .bind(tenant.id.as_slice())
342        .bind("owner@acme.com")
343        .fetch_one(state.control_db.pool())
344        .await
345        .unwrap();
346        assert_eq!(role, "owner");
347        assert!(accepted_at.is_some());
348
349        // Session cookie minted.
350        assert!(result.set_cookie.contains("allowthem_dashboard_session="));
351    }
352
353    #[tokio::test]
354    async fn signup_slug_conflict_compensates_dashboard_user() {
355        let (state, _dir) = test_dashboard_state().await;
356
357        // Pre-insert a tenant with the slug we'll race against. Easiest path:
358        // run signup once with that slug, then signup again with a *different*
359        // email to force step 2 to fail with SlugTaken.
360        dashboard_signup(
361            &state,
362            DashboardSignupParams {
363                email: "first@acme.com".into(),
364                password: "supersecret".into(),
365                tenant_name: "Acme".into(),
366                tenant_slug: "acme".into(),
367            },
368        )
369        .await
370        .expect("first signup");
371
372        let result = dashboard_signup(
373            &state,
374            DashboardSignupParams {
375                email: "second@acme.com".into(),
376                password: "supersecret".into(),
377                tenant_name: "Acme Two".into(),
378                tenant_slug: "acme".into(), // collide
379            },
380        )
381        .await;
382        let Err(err) = result else {
383            panic!("second signup must fail");
384        };
385        assert!(matches!(
386            err,
387            DashboardSignupError::ProvisionFailed(SaasError::SlugTaken)
388        ));
389
390        // Compensation removed the second dashboard user.
391        let email = Email::new("second@acme.com".into()).unwrap();
392        let res = state.ath.db().get_user_by_email(&email).await;
393        assert!(matches!(res, Err(AuthError::NotFound)));
394
395        // Control plane has exactly one tenant.
396        let tenants = state.control_db.list_tenants().await.unwrap();
397        assert_eq!(tenants.len(), 1);
398    }
399
400    #[tokio::test]
401    async fn signup_email_taken() {
402        let (state, _dir) = test_dashboard_state().await;
403
404        // Pre-insert a dashboard user.
405        let email = Email::new("dup@acme.com".into()).unwrap();
406        state
407            .ath
408            .db()
409            .create_user(email, "pw123456", None, None)
410            .await
411            .unwrap();
412
413        let result = dashboard_signup(
414            &state,
415            DashboardSignupParams {
416                email: "dup@acme.com".into(),
417                password: "anotherpw".into(),
418                tenant_name: "Dup".into(),
419                tenant_slug: "dup".into(),
420            },
421        )
422        .await;
423        let Err(err) = result else {
424            panic!("expected EmailTaken");
425        };
426        assert!(matches!(err, DashboardSignupError::EmailTaken));
427
428        // No tenant artifacts created.
429        let tenants = state.control_db.list_tenants().await.unwrap();
430        assert!(tenants.is_empty());
431    }
432
433    #[tokio::test]
434    async fn signup_invalid_email() {
435        let (state, _dir) = test_dashboard_state().await;
436        let result = dashboard_signup(
437            &state,
438            DashboardSignupParams {
439                email: "not-an-email".into(),
440                password: "supersecret".into(),
441                tenant_name: "X".into(),
442                tenant_slug: "xyz".into(),
443            },
444        )
445        .await;
446        let Err(err) = result else {
447            panic!("expected InvalidEmail");
448        };
449        assert!(matches!(err, DashboardSignupError::InvalidEmail));
450    }
451
452    #[tokio::test]
453    async fn provision_tenant_for_user_happy_path() {
454        let (state, _dir) = test_dashboard_state().await;
455        let email = Email::new("alice@acme.com".into()).unwrap();
456        let user = state
457            .ath
458            .db()
459            .create_user(email, "supersecret", None, None)
460            .await
461            .unwrap();
462
463        let result =
464            provision_tenant_for_user(&state, user.id, "Alice Co".into(), "alice-co".into())
465                .await
466                .expect("provision_tenant_for_user");
467
468        assert_eq!(result.tenant.slug, "alice-co");
469        assert_eq!(result.tenant.owner_email, "alice@acme.com");
470    }
471
472    #[tokio::test]
473    async fn provision_tenant_for_user_slug_taken_propagates() {
474        let (state, _dir) = test_dashboard_state().await;
475        let email = Email::new("alice@acme.com".into()).unwrap();
476        let user = state
477            .ath
478            .db()
479            .create_user(email, "supersecret", None, None)
480            .await
481            .unwrap();
482
483        // First call wins.
484        provision_tenant_for_user(&state, user.id, "Alice".into(), "alice".into())
485            .await
486            .expect("first provision");
487
488        // Second call with the same slug fails through SaasError::SlugTaken.
489        let result =
490            provision_tenant_for_user(&state, user.id, "Alice 2".into(), "alice".into()).await;
491        let Err(err) = result else {
492            panic!("expected slug-taken error");
493        };
494        assert!(matches!(
495            err,
496            ProvisionForUserError::Provision(SaasError::SlugTaken)
497        ));
498    }
499
500    #[tokio::test]
501    async fn provision_tenant_for_user_user_not_found() {
502        let (state, _dir) = test_dashboard_state().await;
503        let result =
504            provision_tenant_for_user(&state, UserId::new(), "Ghost".into(), "ghost".into()).await;
505        let Err(err) = result else {
506            panic!("expected UserNotFound");
507        };
508        assert!(matches!(err, ProvisionForUserError::UserNotFound));
509    }
510}