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