Skip to main content

apiplant_server/
email_auth.rs

1//! The endpoints that reach somebody through their mailbox.
2//!
3//! Three flows, one premise: **holding a link sent to an address is proof of
4//! controlling that address**, and that proof is worth as much as a password
5//! for exactly one action.
6//!
7//! | Flow | Endpoints |
8//! |------|-----------|
9//! | Invite somebody into an organisation | `POST <base>/auth/invitations`, `GET <base>/auth/invitations/{token}`, `POST <base>/auth/invitations/{token}/accept` |
10//! | Confirm an address | `POST <base>/auth/verify-email`, `POST <base>/auth/verify-email/resend` |
11//! | Reset a forgotten password | `POST <base>/auth/password/forgot`, `POST <base>/auth/password/reset` |
12//!
13//! ## None of this is mounted without a mailer
14//!
15//! Every route here is registered only when the app has an `[email]` provider
16//! *and* the matching `[auth]` flag is on — see
17//! [`AppState::invitations_enabled`](crate::state::AppState) and its
18//! neighbours. A deployment that cannot send mail does not answer 500 on a
19//! password reset, it does not answer at all, and the dashboard and console are
20//! told through the admin manifest so they never show the button. A door that
21//! cannot open is worse than one that isn't there.
22//!
23//! ## What the tokens are
24//!
25//! Random 256-bit strings, mailed once, stored only as a SHA-256 hash (see
26//! [`Authenticator::generate_link_token`]). Each is single-use and expires;
27//! spending one stamps the row so the copy left in a mailbox is inert. A
28//! password reset additionally invalidates every *other* outstanding reset for
29//! that account, because "I asked twice and used the first" should not leave a
30//! second key under the mat.
31//!
32//! ## What is deliberately not said out loud
33//!
34//! `POST /auth/password/forgot` and `/auth/verify-email/resend` answer `202`
35//! whatever happens. Answering "no such account" would turn either endpoint
36//! into a membership oracle for any address somebody cares to try. The person
37//! who really owns the address learns the truth in the only place they should:
38//! their inbox.
39
40use apiplant_auth::Authenticator;
41use chrono::{DateTime, Duration, Utc};
42use ntex::web::types::{Json, Path, State};
43use ntex::web::{HttpRequest, HttpResponse};
44use serde_json::{json, Map, Value};
45use uuid::Uuid;
46
47use crate::auth_routes::{auth_spec, quote, table, VERIFIED_AT_FIELD};
48use crate::emails::{self, Links};
49use crate::response::{db_error, error};
50use crate::state::AppState;
51
52/// `auth_token.kind` for an address confirmation.
53const KIND_VERIFICATION: &str = "email_verification";
54/// `auth_token.kind` for a password reset.
55const KIND_RESET: &str = "password_reset";
56
57// --- invitations -----------------------------------------------------------
58
59/// `POST <base>/auth/invitations` — invite an address into the active
60/// organisation.
61///
62/// The person invited need not have an account; that is the whole reason this
63/// exists beside `POST <base>/membership`, which can only add somebody who has
64/// already registered.
65///
66/// Issued by anyone who may add members — `role:admin` in a default app, or
67/// whatever the app's `membership` model says `create` takes. The check is
68/// against `membership` rather than against `invitation` on purpose: an
69/// invitation is a membership that has not happened yet, and having two
70/// answers to "who may let people in" is how they end up disagreeing.
71///
72/// Inviting an address that already has a pending invitation **replaces** it,
73/// which is what somebody clicking "invite" a second time means. The earlier
74/// link stops working.
75pub async fn create_invitation(
76    req: HttpRequest,
77    state: State<AppState>,
78    body: Json<Value>,
79) -> HttpResponse {
80    let Some(principal) = state.resolve_principal(&req).await else {
81        return error(401, "authentication required");
82    };
83    let Some(org) = state.active_org(&req, &Some(principal.clone())) else {
84        return error(400, "no active organization — pick one with X-Organization");
85    };
86    if !may_invite(&state, &principal, org) {
87        return error(403, "you may not add people to this organization");
88    }
89
90    let Some(invitation_r) = state.app.resources.get("invitation") else {
91        return error(500, "no invitation resource");
92    };
93    let spec = auth_spec(&state);
94
95    let address = body
96        .get("email")
97        .or_else(|| body.get(&spec.identity_field))
98        .and_then(|v| v.as_str())
99        .map(str::trim)
100        .unwrap_or_default()
101        .to_string();
102    if address.is_empty() {
103        return error(400, "`email` is required");
104    }
105    let role = body
106        .get("role")
107        .and_then(|v| v.as_str())
108        .map(str::trim)
109        .filter(|role| !role.is_empty())
110        .unwrap_or("member")
111        .to_string();
112
113    // Somebody already inside does not need letting in, and an invitation that
114    // resolved to "you are already a member" would be a confusing email.
115    match already_a_member(&state, &address, org).await {
116        Ok(true) => return error(409, "they are already in this organization"),
117        Ok(false) => {}
118        Err(resp) => return resp,
119    }
120
121    // A second invitation supersedes the first rather than sitting beside it:
122    // two live links to the same organisation is a fact nobody wants to reason
123    // about, least of all when revoking one.
124    if let Some(invitation_tbl) = table(&state, "invitation") {
125        let sql = format!(
126            "DELETE FROM {invitation_tbl} \
127             WHERE organization_id = $1::uuid AND lower(email) = lower($2) \
128               AND accepted_at IS NULL"
129        );
130        if let Err(e) = state
131            .db
132            .raw_json(
133                &sql,
134                &[Value::String(org.to_string()), Value::String(address.clone())],
135            )
136            .await
137        {
138            return db_error(e);
139        }
140    }
141
142    let ttl = state.app.config.auth.invite_ttl_secs;
143    let (plaintext, hash) = Authenticator::generate_link_token("inv");
144
145    let mut data = Map::new();
146    data.insert("email".into(), Value::String(address.clone()));
147    data.insert("role".into(), Value::String(role.clone()));
148    data.insert("token_hash".into(), Value::String(hash));
149    data.insert("organization_id".into(), Value::String(org.to_string()));
150    data.insert(
151        "invited_by".into(),
152        Value::String(principal.user_id.to_string()),
153    );
154    data.insert("expires_at".into(), Value::String(rfc3339_in(ttl as i64)));
155
156    let row = match state.db.create(invitation_r, &data).await {
157        Ok(row) => row,
158        Err(e) => return db_error(e),
159    };
160
161    let organization = organization_name(&state, org).await;
162    let inviter = inviter_name(&state, principal.user_id).await;
163    let message = emails::invitation(
164        &Links::from_app(&state.app),
165        &organization,
166        inviter.as_deref(),
167        &plaintext,
168        &emails::humanise(ttl),
169    );
170
171    // A row nobody was told about is not an invitation, so a send that fails
172    // takes the row with it. Otherwise the admin sees "pending" for a link that
173    // was never delivered and waits for somebody who was never asked.
174    if let Err(resp) = send(&state, message.to(&address)).await {
175        if let (Some(invitation_tbl), Some(id)) = (
176            table(&state, "invitation"),
177            row.get("id").and_then(|v| v.as_str()),
178        ) {
179            let sql = format!("DELETE FROM {invitation_tbl} WHERE id = $1::uuid");
180            let _ = state
181                .db
182                .raw_json(&sql, &[Value::String(id.to_string())])
183                .await;
184        }
185        return resp;
186    }
187
188    HttpResponse::Created().json(&json!({ "invitation": row }))
189}
190
191/// `GET <base>/auth/invitations/{token}` — what a link is for, before anyone
192/// commits to it.
193///
194/// Anonymous by design: the token *is* the credential, and the person holding
195/// it has no account yet. It answers with the organisation's name, the address
196/// it was sent to, and whether that address already has an account — which is
197/// what tells the page whether to ask for a new password or just a click.
198///
199/// Nothing here is secret to the holder of the link, and nothing else is
200/// returned: no member list, no inviter's address, no organisation id.
201pub async fn preview_invitation(state: State<AppState>, token: Path<String>) -> HttpResponse {
202    let invitation = match live_invitation(&state, &token).await {
203        Ok(row) => row,
204        Err(resp) => return resp,
205    };
206
207    let org = invitation
208        .get("organization_id")
209        .and_then(|v| v.as_str())
210        .and_then(|s| Uuid::parse_str(s).ok());
211    let organization = match org {
212        Some(org) => organization_name(&state, org).await,
213        None => state.app.display_name(),
214    };
215    let address = invitation
216        .get("email")
217        .and_then(|v| v.as_str())
218        .unwrap_or_default();
219
220    let has_account = match find_user_by_identity(&state, address).await {
221        Ok(found) => found.is_some(),
222        Err(resp) => return resp,
223    };
224
225    HttpResponse::Ok().json(&json!({
226        "email": address,
227        "organization": organization,
228        "role": invitation.get("role").cloned().unwrap_or(Value::Null),
229        "expires_at": invitation.get("expires_at").cloned().unwrap_or(Value::Null),
230        // False means "choose a password"; true means "you already have one".
231        "has_account": has_account,
232        "identity_field": auth_spec(&state).identity_field,
233    }))
234}
235
236/// `POST <base>/auth/invitations/{token}/accept` — take the invitation up.
237///
238/// Two shapes, decided by whether the address already has an account:
239///
240/// * **No account** — the body carries `password` (plus whatever else the
241///   `user` model asks a new person for) and the account is created here. It is
242///   marked as having a confirmed address without a second email: opening this
243///   link is the proof that a confirmation email would have been asking for.
244/// * **An account exists** — nothing is created and no password is wanted. The
245///   token proves control of the address the account is registered to, which is
246///   the same thing a login proves.
247///
248/// Either way the membership is created, the invitation is stamped as accepted
249/// rather than deleted (so "who let them in" survives), and a session token
250/// comes back — nobody should have to sign in immediately after proving who
251/// they are.
252pub async fn accept_invitation(
253    req: HttpRequest,
254    state: State<AppState>,
255    token: Path<String>,
256    body: Json<Map<String, Value>>,
257) -> HttpResponse {
258    let invitation = match live_invitation(&state, &token).await {
259        Ok(row) => row,
260        Err(resp) => return resp,
261    };
262    let (Some(invitation_id), Some(org), address) = (
263        invitation
264            .get("id")
265            .and_then(|v| v.as_str())
266            .and_then(|s| Uuid::parse_str(s).ok()),
267        invitation
268            .get("organization_id")
269            .and_then(|v| v.as_str())
270            .and_then(|s| Uuid::parse_str(s).ok()),
271        invitation
272            .get("email")
273            .and_then(|v| v.as_str())
274            .unwrap_or_default()
275            .to_string(),
276    ) else {
277        return error(500, "invitation is missing its organization");
278    };
279
280    let spec = auth_spec(&state);
281    let user_id = match find_user_by_identity(&state, &address).await {
282        Ok(Some(id)) => id,
283        Err(resp) => return resp,
284        Ok(None) => {
285            // A brand-new account. The address is pinned to the one the
286            // invitation was sent to: letting the body name a different one
287            // would turn any invitation into a free account at any address.
288            let mut data = body.into_inner();
289            let password = match data
290                .remove("password")
291                .and_then(|v| v.as_str().map(String::from))
292                .filter(|p| !p.is_empty())
293            {
294                Some(password) => password,
295                None => return error(400, "`password` is required to create your account"),
296            };
297            let hash = match state.auth.hash_password(&password) {
298                Ok(hash) => hash,
299                Err(_) => return error(500, "failed to hash password"),
300            };
301            data.insert(spec.identity_field.clone(), Value::String(address.clone()));
302            data.insert(spec.password_field.clone(), Value::String(hash));
303            // Opening a link sent to this address *is* the confirmation, so an
304            // app that requires one must not then send a second.
305            if state
306                .app
307                .resources
308                .get("user")
309                .is_some_and(|user| user.fields.contains_key(VERIFIED_AT_FIELD))
310            {
311                data.insert(VERIFIED_AT_FIELD.into(), Value::String(rfc3339_in(0)));
312            }
313
314            match crate::auth_routes::create_account(&state, &req, data).await {
315                Ok((id, _)) => id,
316                Err(resp) => return resp,
317            }
318        }
319    };
320
321    // Being invited twice, or being added by hand while the invitation sat in a
322    // mailbox, must not produce two memberships.
323    if let Err(resp) = ensure_membership(
324        &state,
325        user_id,
326        org,
327        invitation.get("role").and_then(|v| v.as_str()),
328    )
329    .await
330    {
331        return resp;
332    }
333
334    if let Some(invitation_tbl) = table(&state, "invitation") {
335        let sql = format!(
336            "UPDATE {invitation_tbl} SET accepted_at = now() WHERE id = $1::uuid"
337        );
338        if let Err(e) = state
339            .db
340            .raw_json(&sql, &[Value::String(invitation_id.to_string())])
341            .await
342        {
343            return db_error(e);
344        }
345    }
346
347    match state.auth.issue_token(user_id) {
348        Ok(session) => HttpResponse::Ok().json(&json!({
349            "token": session,
350            "organization_id": org.to_string(),
351        })),
352        Err(_) => error(500, "failed to issue token"),
353    }
354}
355
356// --- confirming an address -------------------------------------------------
357
358/// Mint a confirmation token for `user_id` and mail it to `address`.
359///
360/// Called from `POST <base>/auth/register` and from the resend endpoint. The
361/// `Err` is a ready-made response: a registration whose confirmation could not
362/// be sent has produced an account nobody can sign in to, so it is worth
363/// failing loudly rather than leaving somebody waiting for a message that was
364/// never going to arrive.
365pub async fn send_verification(
366    state: &AppState,
367    user_id: Uuid,
368    address: &str,
369) -> Result<(), HttpResponse> {
370    let ttl = state.app.config.auth.verification_ttl_secs;
371    let plaintext = mint_token(state, user_id, KIND_VERIFICATION, ttl).await?;
372    let message = emails::verification(
373        &Links::from_app(&state.app),
374        &plaintext,
375        &emails::humanise(ttl),
376    );
377    send(state, message.to(address)).await
378}
379
380/// `POST <base>/auth/verify-email` — spend a confirmation token.
381///
382/// Answers with a session token: somebody who has just proved they read the
383/// mailbox an account is registered to should not then be asked to sign in.
384pub async fn verify_email(state: State<AppState>, body: Json<Value>) -> HttpResponse {
385    let Some(token) = body.get("token").and_then(|v| v.as_str()) else {
386        return error(400, "`token` is required");
387    };
388    let user_id = match spend_token(&state, token, KIND_VERIFICATION).await {
389        Ok(id) => id,
390        Err(resp) => return resp,
391    };
392
393    let Some(user_tbl) = table(&state, "user") else {
394        return error(500, "missing user resource");
395    };
396    // `coalesce` so that confirming twice does not rewrite the date somebody
397    // first confirmed on.
398    let sql = format!(
399        "UPDATE {user_tbl} SET {VERIFIED_AT_FIELD} = coalesce({VERIFIED_AT_FIELD}, now()) \
400         WHERE id = $1::uuid"
401    );
402    if let Err(e) = state
403        .db
404        .raw_json(&sql, &[Value::String(user_id.to_string())])
405        .await
406    {
407        return db_error(e);
408    }
409
410    match state.auth.issue_token(user_id) {
411        Ok(session) => HttpResponse::Ok().json(&json!({ "token": session, "verified": true })),
412        Err(_) => error(500, "failed to issue token"),
413    }
414}
415
416/// `POST <base>/auth/verify-email/resend` — send the confirmation again.
417///
418/// Always `202`, whether or not the address has an account and whether or not
419/// it was already confirmed. See the module docs: an endpoint that answers
420/// truthfully here tells anybody who asks which addresses are registered.
421pub async fn resend_verification(state: State<AppState>, body: Json<Value>) -> HttpResponse {
422    let spec = auth_spec(&state);
423    let address = body
424        .get("email")
425        .or_else(|| body.get(&spec.identity_field))
426        .and_then(|v| v.as_str())
427        .map(str::trim)
428        .unwrap_or_default()
429        .to_string();
430
431    if !address.is_empty() {
432        if let Ok(Some(user_id)) = find_unverified_user(&state, &address).await {
433            // A failure to send is logged, not reported: the answer is the same
434            // either way, and it has to be.
435            if let Err(_resp) = send_verification(&state, user_id, &address).await {
436                tracing::warn!("could not send a verification email");
437            }
438        }
439    }
440
441    accepted("If that address needs confirming, a new link is on its way.")
442}
443
444// --- resetting a password --------------------------------------------------
445
446/// `POST <base>/auth/password/forgot` — mail a reset link.
447///
448/// Always `202`. See the module docs.
449pub async fn forgot_password(state: State<AppState>, body: Json<Value>) -> HttpResponse {
450    let spec = auth_spec(&state);
451    let address = body
452        .get("email")
453        .or_else(|| body.get(&spec.identity_field))
454        .and_then(|v| v.as_str())
455        .map(str::trim)
456        .unwrap_or_default()
457        .to_string();
458
459    if !address.is_empty() {
460        if let Ok(Some(user_id)) = find_user_by_identity(&state, &address).await {
461            let ttl = state.app.config.auth.password_reset_ttl_secs;
462            match mint_token(&state, user_id, KIND_RESET, ttl).await {
463                Ok(plaintext) => {
464                    let message = emails::password_reset(
465                        &Links::from_app(&state.app),
466                        &plaintext,
467                        &emails::humanise(ttl),
468                    );
469                    if send(&state, message.to(&address)).await.is_err() {
470                        tracing::warn!("could not send a password reset email");
471                    }
472                }
473                Err(_) => tracing::warn!("could not mint a password reset token"),
474            }
475        }
476    }
477
478    accepted("If that address has an account, a reset link is on its way.")
479}
480
481/// `POST <base>/auth/password/reset` — spend a reset token and set the password.
482///
483/// Every other outstanding reset for the account is spent at the same time: two
484/// links asked for in a moment of confusion should not leave the second one
485/// working after the first has been used.
486///
487/// The address is marked confirmed as a side effect, because it now has been —
488/// the link only reached somebody who reads it.
489pub async fn reset_password(state: State<AppState>, body: Json<Value>) -> HttpResponse {
490    let Some(token) = body.get("token").and_then(|v| v.as_str()) else {
491        return error(400, "`token` is required");
492    };
493    let Some(password) = body
494        .get("password")
495        .and_then(|v| v.as_str())
496        .filter(|p| !p.is_empty())
497    else {
498        return error(400, "`password` is required");
499    };
500
501    let user_id = match spend_token(&state, token, KIND_RESET).await {
502        Ok(id) => id,
503        Err(resp) => return resp,
504    };
505    let hash = match state.auth.hash_password(password) {
506        Ok(hash) => hash,
507        Err(_) => return error(500, "failed to hash password"),
508    };
509
510    let spec = auth_spec(&state);
511    let Some(user_tbl) = table(&state, "user") else {
512        return error(500, "missing user resource");
513    };
514    let sql = format!(
515        "UPDATE {user_tbl} \
516         SET {pw} = $1, {VERIFIED_AT_FIELD} = coalesce({VERIFIED_AT_FIELD}, now()) \
517         WHERE id = $2::uuid",
518        pw = quote(&spec.password_field),
519    );
520    if let Err(e) = state
521        .db
522        .raw_json(
523            &sql,
524            &[Value::String(hash), Value::String(user_id.to_string())],
525        )
526        .await
527    {
528        return db_error(e);
529    }
530
531    if let Some(token_tbl) = table(&state, "auth_token") {
532        let sql = format!(
533            "UPDATE {token_tbl} SET used_at = now() \
534             WHERE user_id = $1::uuid AND kind = $2 AND used_at IS NULL"
535        );
536        let _ = state
537            .db
538            .raw_json(
539                &sql,
540                &[
541                    Value::String(user_id.to_string()),
542                    Value::String(KIND_RESET.into()),
543                ],
544            )
545            .await;
546    }
547
548    match state.auth.issue_token(user_id) {
549        Ok(session) => HttpResponse::Ok().json(&json!({ "token": session })),
550        Err(_) => error(500, "failed to issue token"),
551    }
552}
553
554// --- shared machinery ------------------------------------------------------
555
556/// Whether `principal` may add people to `org`.
557///
558/// Read from the `membership` model's `create` policy so that an app which has
559/// changed who manages its team gets invitations that agree with it. A policy
560/// this code cannot express as a role check — `public`, say — falls back to
561/// requiring `admin`, because handing out organisation membership is not
562/// something to open up by accident.
563fn may_invite(state: &AppState, principal: &apiplant_auth::Principal, org: Uuid) -> bool {
564    invite_policy(
565        state
566            .app
567            .resources
568            .get("membership")
569            .map(|membership| &membership.permissions.create),
570        principal,
571        org,
572    )
573}
574
575/// [`may_invite`] with the policy handed in, so the rule can be checked without
576/// a database behind it.
577fn invite_policy(
578    create: Option<&apiplant_core::Access>,
579    principal: &apiplant_auth::Principal,
580    org: Uuid,
581) -> bool {
582    use apiplant_core::Access;
583    match create {
584        Some(Access::Role(role)) => principal.has_role_in(org, role),
585        Some(Access::Member | Access::Owner | Access::Authenticated) => principal.is_member(org),
586        _ => principal.is_admin_of(org),
587    }
588}
589
590/// The invitation a token names, if it is still good for something.
591///
592/// Expired, already accepted and never existed are one answer — `404` — on
593/// purpose: they are all "this link does nothing", and distinguishing them
594/// would let somebody probe for which tokens once existed.
595async fn live_invitation(state: &AppState, token: &str) -> Result<Value, HttpResponse> {
596    let Some(invitation_tbl) = table(state, "invitation") else {
597        return Err(error(500, "no invitation resource"));
598    };
599    let hash = Authenticator::hash_link_token(token.trim());
600    let sql = format!(
601        "SELECT id::text AS id, email, role, organization_id::text AS organization_id, \
602                expires_at::text AS expires_at \
603         FROM {invitation_tbl} \
604         WHERE token_hash = $1 AND accepted_at IS NULL AND expires_at > now() \
605         LIMIT 1"
606    );
607    let rows = state
608        .db
609        .raw_json(&sql, &[Value::String(hash)])
610        .await
611        .map_err(db_error)?;
612    match rows.as_array().and_then(|rows| rows.first()) {
613        Some(row) => Ok(row.clone()),
614        None => Err(error(404, "this invitation is no longer valid")),
615    }
616}
617
618/// Store a fresh single-use token for `user_id` and return its plaintext.
619async fn mint_token(
620    state: &AppState,
621    user_id: Uuid,
622    kind: &str,
623    ttl_secs: u64,
624) -> Result<String, HttpResponse> {
625    let Some(token_r) = state.app.resources.get("auth_token") else {
626        return Err(error(500, "no auth_token resource"));
627    };
628    let prefix = if kind == KIND_RESET { "reset" } else { "verify" };
629    let (plaintext, hash) = Authenticator::generate_link_token(prefix);
630
631    let mut data = Map::new();
632    data.insert("user_id".into(), Value::String(user_id.to_string()));
633    data.insert("kind".into(), Value::String(kind.to_string()));
634    data.insert("token_hash".into(), Value::String(hash));
635    data.insert(
636        "expires_at".into(),
637        Value::String(rfc3339_in(ttl_secs as i64)),
638    );
639
640    state
641        .db
642        .create(token_r, &data)
643        .await
644        .map_err(db_error)
645        .map(|_| plaintext)
646}
647
648/// Spend a token: claim it, then read whose it was.
649///
650/// The claim is the `UPDATE`, and it is what makes this safe against two clicks
651/// arriving together: `used_at IS NULL` is part of the `WHERE`, so exactly one
652/// of them affects a row and the other affects none. Only the winner goes on to
653/// look the account up, so the second click gets the same 404 as a link that
654/// was never issued.
655async fn spend_token(state: &AppState, token: &str, kind: &str) -> Result<Uuid, HttpResponse> {
656    let Some(token_tbl) = table(state, "auth_token") else {
657        return Err(error(500, "no auth_token resource"));
658    };
659    let hash = Authenticator::hash_link_token(token.trim());
660    let params = [Value::String(hash), Value::String(kind.to_string())];
661
662    let claim = format!(
663        "UPDATE {token_tbl} SET used_at = now() \
664         WHERE token_hash = $1 AND kind = $2 AND used_at IS NULL AND expires_at > now()"
665    );
666    let claimed = state
667        .db
668        .raw_json(&claim, &params)
669        .await
670        .map_err(db_error)?
671        .get("rows_affected")
672        .and_then(|v| v.as_u64())
673        .unwrap_or(0);
674    if claimed == 0 {
675        return Err(error(404, "this link is no longer valid"));
676    }
677
678    let lookup = format!(
679        "SELECT user_id::text AS user_id FROM {token_tbl} \
680         WHERE token_hash = $1 AND kind = $2 LIMIT 1"
681    );
682    let rows = state
683        .db
684        .raw_json(&lookup, &params)
685        .await
686        .map_err(db_error)?;
687
688    rows.as_array()
689        .and_then(|rows| rows.first())
690        .and_then(|row| row.get("user_id"))
691        .and_then(|v| v.as_str())
692        .and_then(|s| Uuid::parse_str(s).ok())
693        .ok_or_else(|| error(404, "this link is no longer valid"))
694}
695
696/// The id of the account registered at `address`, if there is one.
697///
698/// Case-insensitively: nobody types their own address the same way twice, and
699/// an invitation to `Ann@example.com` that misses the account at
700/// `ann@example.com` would create a second account for the same person.
701async fn find_user_by_identity(
702    state: &AppState,
703    address: &str,
704) -> Result<Option<Uuid>, HttpResponse> {
705    let spec = auth_spec(state);
706    let Some(user_tbl) = table(state, "user") else {
707        return Err(error(500, "missing user resource"));
708    };
709    let sql = format!(
710        "SELECT id::text AS id FROM {user_tbl} WHERE lower({ident}) = lower($1) LIMIT 1",
711        ident = quote(&spec.identity_field),
712    );
713    let rows = state
714        .db
715        .raw_json(&sql, &[Value::String(address.to_string())])
716        .await
717        .map_err(db_error)?;
718    Ok(rows
719        .as_array()
720        .and_then(|rows| rows.first())
721        .and_then(|row| row.get("id"))
722        .and_then(|v| v.as_str())
723        .and_then(|s| Uuid::parse_str(s).ok()))
724}
725
726/// The same lookup, restricted to accounts that have *not* confirmed — so a
727/// resend for an already-confirmed address quietly does nothing rather than
728/// mailing a link that would do nothing.
729async fn find_unverified_user(
730    state: &AppState,
731    address: &str,
732) -> Result<Option<Uuid>, HttpResponse> {
733    let spec = auth_spec(state);
734    let Some(user_tbl) = table(state, "user") else {
735        return Err(error(500, "missing user resource"));
736    };
737    let sql = format!(
738        "SELECT id::text AS id FROM {user_tbl} \
739         WHERE lower({ident}) = lower($1) AND {VERIFIED_AT_FIELD} IS NULL LIMIT 1",
740        ident = quote(&spec.identity_field),
741    );
742    let rows = state
743        .db
744        .raw_json(&sql, &[Value::String(address.to_string())])
745        .await
746        .map_err(db_error)?;
747    Ok(rows
748        .as_array()
749        .and_then(|rows| rows.first())
750        .and_then(|row| row.get("id"))
751        .and_then(|v| v.as_str())
752        .and_then(|s| Uuid::parse_str(s).ok()))
753}
754
755/// Whether the account at `address` is already in `org`.
756async fn already_a_member(
757    state: &AppState,
758    address: &str,
759    org: Uuid,
760) -> Result<bool, HttpResponse> {
761    let Some(user_id) = find_user_by_identity(state, address).await? else {
762        return Ok(false);
763    };
764    let Some(membership_tbl) = table(state, "membership") else {
765        return Ok(false);
766    };
767    let sql = format!(
768        "SELECT 1 AS hit FROM {membership_tbl} \
769         WHERE user_id = $1::uuid AND organization_id = $2::uuid LIMIT 1"
770    );
771    let rows = state
772        .db
773        .raw_json(
774            &sql,
775            &[
776                Value::String(user_id.to_string()),
777                Value::String(org.to_string()),
778            ],
779        )
780        .await
781        .map_err(db_error)?;
782    Ok(rows.as_array().is_some_and(|rows| !rows.is_empty()))
783}
784
785/// Put `user_id` in `org` with `role`, unless they are in it already.
786///
787/// Written straight rather than through `POST <base>/membership`, which the
788/// person accepting could not call: they are not a member yet, which is the
789/// whole point.
790async fn ensure_membership(
791    state: &AppState,
792    user_id: Uuid,
793    org: Uuid,
794    role: Option<&str>,
795) -> Result<(), HttpResponse> {
796    let Some(membership_r) = state.app.resources.get("membership") else {
797        return Err(error(500, "no membership resource"));
798    };
799    let Some(membership_tbl) = table(state, "membership") else {
800        return Err(error(500, "no membership resource"));
801    };
802
803    let sql = format!(
804        "SELECT 1 AS hit FROM {membership_tbl} \
805         WHERE user_id = $1::uuid AND organization_id = $2::uuid LIMIT 1"
806    );
807    let rows = state
808        .db
809        .raw_json(
810            &sql,
811            &[
812                Value::String(user_id.to_string()),
813                Value::String(org.to_string()),
814            ],
815        )
816        .await
817        .map_err(db_error)?;
818    if rows.as_array().is_some_and(|rows| !rows.is_empty()) {
819        return Ok(());
820    }
821
822    let mut data = Map::new();
823    data.insert("user_id".into(), Value::String(user_id.to_string()));
824    data.insert("organization_id".into(), Value::String(org.to_string()));
825    if let Some(role) = role.filter(|role| !role.is_empty()) {
826        data.insert("role".into(), Value::String(role.to_string()));
827    }
828    state
829        .db
830        .create(membership_r, &data)
831        .await
832        .map_err(db_error)
833        .map(|_| ())
834}
835
836/// The organisation's own name, for the sentence in the email. Falls back to
837/// the app's name rather than to an id, which would mean nothing to a reader.
838async fn organization_name(state: &AppState, org: Uuid) -> String {
839    let fallback = || state.app.display_name();
840    let Some(org_tbl) = table(state, "organization") else {
841        return fallback();
842    };
843    let sql = format!("SELECT name FROM {org_tbl} WHERE id = $1::uuid LIMIT 1");
844    let rows = match state
845        .db
846        .raw_json(&sql, &[Value::String(org.to_string())])
847        .await
848    {
849        Ok(rows) => rows,
850        Err(_) => return fallback(),
851    };
852    rows.as_array()
853        .and_then(|rows| rows.first())
854        .and_then(|row| row.get("name"))
855        .and_then(|v| v.as_str())
856        .filter(|name| !name.is_empty())
857        .map(str::to_owned)
858        .unwrap_or_else(fallback)
859}
860
861/// How to refer to the person who sent an invitation: their display name if the
862/// app keeps one, otherwise the address they signed in with, otherwise nobody.
863async fn inviter_name(state: &AppState, user_id: Uuid) -> Option<String> {
864    let spec = auth_spec(state);
865    let user_r = state.app.resources.get("user")?;
866    let user_tbl = table(state, "user")?;
867
868    let mut candidates: Vec<String> = Vec::new();
869    for name in ["display_name", "name"] {
870        if user_r.fields.contains_key(name) {
871            candidates.push(quote(name));
872        }
873    }
874    candidates.push(quote(&spec.identity_field));
875
876    let sql = format!(
877        "SELECT coalesce({}) AS who FROM {user_tbl} WHERE id = $1::uuid LIMIT 1",
878        candidates.join(", "),
879    );
880    let rows = state
881        .db
882        .raw_json(&sql, &[Value::String(user_id.to_string())])
883        .await
884        .ok()?;
885    rows.as_array()?
886        .first()?
887        .get("who")?
888        .as_str()
889        .filter(|who| !who.is_empty())
890        .map(str::to_owned)
891}
892
893/// Hand a composed message to the app's mailer.
894///
895/// The `Err` is a `502`, not a `500`: the request was fine and this server is
896/// fine — somebody else's service said no, and that is worth distinguishing in
897/// whatever is reading the logs.
898async fn send(state: &AppState, message: apiplant_email::Message) -> Result<(), HttpResponse> {
899    let Some(mailer) = &state.mailer else {
900        // Unreachable through a route, which is only mounted with a mailer —
901        // but a wrong 502 beats a panic if that ever stops being true.
902        return Err(error(502, "this server cannot send email"));
903    };
904    match mailer.send(&message).await {
905        Ok(_) => Ok(()),
906        Err(e) => {
907            tracing::error!(error = %e, "could not send an email");
908            Err(error(502, "could not send the email — try again shortly"))
909        }
910    }
911}
912
913/// `202` with a message that says nothing about who exists.
914fn accepted(message: &str) -> HttpResponse {
915    HttpResponse::Accepted().json(&json!({ "message": message }))
916}
917
918/// An RFC 3339 timestamp `secs` from now, in the form
919/// [`json_to_sql`](apiplant_db) wants for a `timestamp` column.
920fn rfc3339_in(secs: i64) -> String {
921    let at: DateTime<Utc> = Utc::now() + Duration::seconds(secs);
922    at.to_rfc3339()
923}
924
925#[cfg(test)]
926mod tests {
927    use super::*;
928    use apiplant_core::Access;
929
930    fn principal(org: Uuid, role: &str) -> apiplant_auth::Principal {
931        apiplant_auth::Principal {
932            user_id: Uuid::new_v4(),
933            organizations: vec![apiplant_auth::OrgMembership::new(
934                org,
935                Some(role.to_string()),
936                [],
937            )],
938        }
939    }
940
941    #[test]
942    fn who_may_invite_follows_who_may_add_a_member() {
943        let org = Uuid::new_v4();
944
945        // The default: admins of the organisation, and nobody else in it.
946        let admins = Access::Role("admin".into());
947        assert!(invite_policy(Some(&admins), &principal(org, "admin"), org));
948        assert!(!invite_policy(Some(&admins), &principal(org, "member"), org));
949
950        // An app that lets any member add people gets invitations to match,
951        // rather than a second, stricter answer to the same question.
952        let members = Access::Member;
953        assert!(invite_policy(Some(&members), &principal(org, "member"), org));
954
955        // …but never for an organisation you are not in.
956        assert!(!invite_policy(
957            Some(&members),
958            &principal(org, "member"),
959            Uuid::new_v4()
960        ));
961    }
962
963    #[test]
964    fn a_policy_that_is_not_a_role_check_falls_back_to_admin() {
965        let org = Uuid::new_v4();
966        // Handing out membership of an organisation is not something to open
967        // up because a permission happened to say `public`, and a model with
968        // no `membership` at all is not an invitation to improvise.
969        for policy in [Some(&Access::Public), None] {
970            assert!(!invite_policy(policy, &principal(org, "member"), org));
971            assert!(invite_policy(policy, &principal(org, "admin"), org));
972        }
973    }
974}