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    let ttl = state.app.config.auth.verification_ttl_secs;
372    let plaintext = mint_token(state, user_id, KIND_VERIFICATION, ttl).await?;
373    let message = emails::verification(
374        &Links::from_app(&state.app),
375        &plaintext,
376        &emails::humanise(ttl),
377    );
378    send(state, message.to(address)).await
379}
380
381/// `POST <base>/auth/verify-email` — spend a confirmation token.
382///
383/// Answers with a session token: somebody who has just proved they read the
384/// mailbox an account is registered to should not then be asked to sign in.
385pub async fn verify_email(state: State<AppState>, body: Json<Value>) -> HttpResponse {
386    let Some(token) = body.get("token").and_then(|v| v.as_str()) else {
387        return error(400, "`token` is required");
388    };
389    let user_id = match spend_token(&state, token, KIND_VERIFICATION).await {
390        Ok(id) => id,
391        Err(resp) => return resp,
392    };
393
394    let Some(user_tbl) = table(&state, "user") else {
395        return error(500, "missing user resource");
396    };
397    // `coalesce` so that confirming twice does not rewrite the date somebody
398    // first confirmed on.
399    let sql = format!(
400        "UPDATE {user_tbl} SET {VERIFIED_AT_FIELD} = coalesce({VERIFIED_AT_FIELD}, now()) \
401         WHERE id = $1::uuid"
402    );
403    if let Err(e) = state
404        .db
405        .raw_json(&sql, &[Value::String(user_id.to_string())])
406        .await
407    {
408        return db_error(e);
409    }
410
411    match state.auth.issue_token(user_id) {
412        Ok(session) => HttpResponse::Ok().json(&json!({ "token": session, "verified": true })),
413        Err(_) => error(500, "failed to issue token"),
414    }
415}
416
417/// `POST <base>/auth/verify-email/resend` — send the confirmation again.
418///
419/// Always `202`, whether or not the address has an account and whether or not
420/// it was already confirmed. See the module docs: an endpoint that answers
421/// truthfully here tells anybody who asks which addresses are registered.
422pub async fn resend_verification(state: State<AppState>, body: Json<Value>) -> HttpResponse {
423    let spec = auth_spec(&state);
424    let address = body
425        .get("email")
426        .or_else(|| body.get(&spec.identity_field))
427        .and_then(|v| v.as_str())
428        .map(str::trim)
429        .unwrap_or_default()
430        .to_string();
431
432    if !address.is_empty() {
433        if let Ok(Some(user_id)) = find_unverified_user(&state, &address).await {
434            // A failure to send is logged, not reported: the answer is the same
435            // either way, and it has to be.
436            if let Err(_resp) = send_verification(&state, user_id, &address).await {
437                tracing::warn!("could not send a verification email");
438            }
439        }
440    }
441
442    accepted("If that address needs confirming, a new link is on its way.")
443}
444
445// --- resetting a password --------------------------------------------------
446
447/// `POST <base>/auth/password/forgot` — mail a reset link.
448///
449/// Always `202`. See the module docs.
450pub async fn forgot_password(state: State<AppState>, body: Json<Value>) -> HttpResponse {
451    let spec = auth_spec(&state);
452    let address = body
453        .get("email")
454        .or_else(|| body.get(&spec.identity_field))
455        .and_then(|v| v.as_str())
456        .map(str::trim)
457        .unwrap_or_default()
458        .to_string();
459
460    if !address.is_empty() {
461        if let Ok(Some(user_id)) = find_user_by_identity(&state, &address).await {
462            let ttl = state.app.config.auth.password_reset_ttl_secs;
463            match mint_token(&state, user_id, KIND_RESET, ttl).await {
464                Ok(plaintext) => {
465                    let message = emails::password_reset(
466                        &Links::from_app(&state.app),
467                        &plaintext,
468                        &emails::humanise(ttl),
469                    );
470                    if send(&state, message.to(&address)).await.is_err() {
471                        tracing::warn!("could not send a password reset email");
472                    }
473                }
474                Err(_) => tracing::warn!("could not mint a password reset token"),
475            }
476        }
477    }
478
479    accepted("If that address has an account, a reset link is on its way.")
480}
481
482/// `POST <base>/auth/password/reset` — spend a reset token and set the password.
483///
484/// Every other outstanding reset for the account is spent at the same time: two
485/// links asked for in a moment of confusion should not leave the second one
486/// working after the first has been used.
487///
488/// The address is marked confirmed as a side effect, because it now has been —
489/// the link only reached somebody who reads it.
490pub async fn reset_password(state: State<AppState>, body: Json<Value>) -> HttpResponse {
491    let Some(token) = body.get("token").and_then(|v| v.as_str()) else {
492        return error(400, "`token` is required");
493    };
494    let Some(password) = body
495        .get("password")
496        .and_then(|v| v.as_str())
497        .filter(|p| !p.is_empty())
498    else {
499        return error(400, "`password` is required");
500    };
501
502    let user_id = match spend_token(&state, token, KIND_RESET).await {
503        Ok(id) => id,
504        Err(resp) => return resp,
505    };
506    let hash = match state.auth.hash_password(password) {
507        Ok(hash) => hash,
508        Err(_) => return error(500, "failed to hash password"),
509    };
510
511    let spec = auth_spec(&state);
512    let Some(user_tbl) = table(&state, "user") else {
513        return error(500, "missing user resource");
514    };
515    let sql = format!(
516        "UPDATE {user_tbl} \
517         SET {pw} = $1, {VERIFIED_AT_FIELD} = coalesce({VERIFIED_AT_FIELD}, now()) \
518         WHERE id = $2::uuid",
519        pw = quote(&spec.password_field),
520    );
521    if let Err(e) = state
522        .db
523        .raw_json(
524            &sql,
525            &[Value::String(hash), Value::String(user_id.to_string())],
526        )
527        .await
528    {
529        return db_error(e);
530    }
531
532    if let Some(token_tbl) = table(&state, "auth_token") {
533        let sql = format!(
534            "UPDATE {token_tbl} SET used_at = now() \
535             WHERE user_id = $1::uuid AND kind = $2 AND used_at IS NULL"
536        );
537        let _ = state
538            .db
539            .raw_json(
540                &sql,
541                &[
542                    Value::String(user_id.to_string()),
543                    Value::String(KIND_RESET.into()),
544                ],
545            )
546            .await;
547    }
548
549    match state.auth.issue_token(user_id) {
550        Ok(session) => HttpResponse::Ok().json(&json!({ "token": session })),
551        Err(_) => error(500, "failed to issue token"),
552    }
553}
554
555// --- shared machinery ------------------------------------------------------
556
557/// Whether `principal` may add people to `org`.
558///
559/// Read from the `membership` model's `create` policy so that an app which has
560/// changed who manages its team gets invitations that agree with it. A policy
561/// this code cannot express as a role check — `public`, say — falls back to
562/// requiring `admin`, because handing out organisation membership is not
563/// something to open up by accident.
564fn may_invite(state: &AppState, principal: &apiplant_auth::Principal, org: Uuid) -> bool {
565    invite_policy(
566        state
567            .app
568            .resources
569            .get("membership")
570            .map(|membership| &membership.permissions.create),
571        principal,
572        org,
573    )
574}
575
576/// [`may_invite`] with the policy handed in, so the rule can be checked without
577/// a database behind it.
578fn invite_policy(
579    create: Option<&apiplant_core::Access>,
580    principal: &apiplant_auth::Principal,
581    org: Uuid,
582) -> bool {
583    use apiplant_core::Access;
584    match create {
585        Some(Access::Role(role)) => principal.has_role_in(org, role),
586        Some(Access::Member | Access::Owner | Access::Authenticated) => principal.is_member(org),
587        _ => principal.is_admin_of(org),
588    }
589}
590
591/// The invitation a token names, if it is still good for something.
592///
593/// Expired, already accepted and never existed are one answer — `404` — on
594/// purpose: they are all "this link does nothing", and distinguishing them
595/// would let somebody probe for which tokens once existed.
596async fn live_invitation(state: &AppState, token: &str) -> Result<Value, HttpResponse> {
597    let Some(invitation_tbl) = table(state, "invitation") else {
598        return Err(error(500, "no invitation resource"));
599    };
600    let hash = Authenticator::hash_link_token(token.trim());
601    let sql = format!(
602        "SELECT id::text AS id, email, role, organization_id::text AS organization_id, \
603                expires_at::text AS expires_at \
604         FROM {invitation_tbl} \
605         WHERE token_hash = $1 AND accepted_at IS NULL AND expires_at > now() \
606         LIMIT 1"
607    );
608    let rows = state
609        .db
610        .raw_json(&sql, &[Value::String(hash)])
611        .await
612        .map_err(db_error)?;
613    match rows.as_array().and_then(|rows| rows.first()) {
614        Some(row) => Ok(row.clone()),
615        None => Err(error(404, "this invitation is no longer valid")),
616    }
617}
618
619/// Store a fresh single-use token for `user_id` and return its plaintext.
620async fn mint_token(
621    state: &AppState,
622    user_id: Uuid,
623    kind: &str,
624    ttl_secs: u64,
625) -> Result<String, HttpResponse> {
626    let Some(token_r) = state.app.resources.get("auth_token") else {
627        return Err(error(500, "no auth_token resource"));
628    };
629    let prefix = if kind == KIND_RESET {
630        "reset"
631    } else {
632        "verify"
633    };
634    let (plaintext, hash) = Authenticator::generate_link_token(prefix);
635
636    let mut data = Map::new();
637    data.insert("user_id".into(), Value::String(user_id.to_string()));
638    data.insert("kind".into(), Value::String(kind.to_string()));
639    data.insert("token_hash".into(), Value::String(hash));
640    data.insert(
641        "expires_at".into(),
642        Value::String(rfc3339_in(ttl_secs as i64)),
643    );
644
645    state
646        .db
647        .create(token_r, &data)
648        .await
649        .map_err(db_error)
650        .map(|_| plaintext)
651}
652
653/// Spend a token: claim it, then read whose it was.
654///
655/// The claim is the `UPDATE`, and it is what makes this safe against two clicks
656/// arriving together: `used_at IS NULL` is part of the `WHERE`, so exactly one
657/// of them affects a row and the other affects none. Only the winner goes on to
658/// look the account up, so the second click gets the same 404 as a link that
659/// was never issued.
660async fn spend_token(state: &AppState, token: &str, kind: &str) -> Result<Uuid, HttpResponse> {
661    let Some(token_tbl) = table(state, "auth_token") else {
662        return Err(error(500, "no auth_token resource"));
663    };
664    let hash = Authenticator::hash_link_token(token.trim());
665    let params = [Value::String(hash), Value::String(kind.to_string())];
666
667    let claim = format!(
668        "UPDATE {token_tbl} SET used_at = now() \
669         WHERE token_hash = $1 AND kind = $2 AND used_at IS NULL AND expires_at > now()"
670    );
671    let claimed = state
672        .db
673        .raw_json(&claim, &params)
674        .await
675        .map_err(db_error)?
676        .get("rows_affected")
677        .and_then(|v| v.as_u64())
678        .unwrap_or(0);
679    if claimed == 0 {
680        return Err(error(404, "this link is no longer valid"));
681    }
682
683    let lookup = format!(
684        "SELECT user_id::text AS user_id FROM {token_tbl} \
685         WHERE token_hash = $1 AND kind = $2 LIMIT 1"
686    );
687    let rows = state
688        .db
689        .raw_json(&lookup, &params)
690        .await
691        .map_err(db_error)?;
692
693    rows.as_array()
694        .and_then(|rows| rows.first())
695        .and_then(|row| row.get("user_id"))
696        .and_then(|v| v.as_str())
697        .and_then(|s| Uuid::parse_str(s).ok())
698        .ok_or_else(|| error(404, "this link is no longer valid"))
699}
700
701/// The id of the account registered at `address`, if there is one.
702///
703/// Case-insensitively: nobody types their own address the same way twice, and
704/// an invitation to `Ann@example.com` that misses the account at
705/// `ann@example.com` would create a second account for the same person.
706async fn find_user_by_identity(
707    state: &AppState,
708    address: &str,
709) -> Result<Option<Uuid>, HttpResponse> {
710    let spec = auth_spec(state);
711    let Some(user_tbl) = table(state, "user") else {
712        return Err(error(500, "missing user resource"));
713    };
714    let sql = format!(
715        "SELECT id::text AS id FROM {user_tbl} WHERE lower({ident}) = lower($1) LIMIT 1",
716        ident = quote(&spec.identity_field),
717    );
718    let rows = state
719        .db
720        .raw_json(&sql, &[Value::String(address.to_string())])
721        .await
722        .map_err(db_error)?;
723    Ok(rows
724        .as_array()
725        .and_then(|rows| rows.first())
726        .and_then(|row| row.get("id"))
727        .and_then(|v| v.as_str())
728        .and_then(|s| Uuid::parse_str(s).ok()))
729}
730
731/// The same lookup, restricted to accounts that have *not* confirmed — so a
732/// resend for an already-confirmed address quietly does nothing rather than
733/// mailing a link that would do nothing.
734async fn find_unverified_user(
735    state: &AppState,
736    address: &str,
737) -> Result<Option<Uuid>, HttpResponse> {
738    let spec = auth_spec(state);
739    let Some(user_tbl) = table(state, "user") else {
740        return Err(error(500, "missing user resource"));
741    };
742    let sql = format!(
743        "SELECT id::text AS id FROM {user_tbl} \
744         WHERE lower({ident}) = lower($1) AND {VERIFIED_AT_FIELD} IS NULL LIMIT 1",
745        ident = quote(&spec.identity_field),
746    );
747    let rows = state
748        .db
749        .raw_json(&sql, &[Value::String(address.to_string())])
750        .await
751        .map_err(db_error)?;
752    Ok(rows
753        .as_array()
754        .and_then(|rows| rows.first())
755        .and_then(|row| row.get("id"))
756        .and_then(|v| v.as_str())
757        .and_then(|s| Uuid::parse_str(s).ok()))
758}
759
760/// Whether the account at `address` is already in `org`.
761async fn already_a_member(
762    state: &AppState,
763    address: &str,
764    org: Uuid,
765) -> Result<bool, HttpResponse> {
766    let Some(user_id) = find_user_by_identity(state, address).await? else {
767        return Ok(false);
768    };
769    let Some(membership_tbl) = table(state, "membership") else {
770        return Ok(false);
771    };
772    let sql = format!(
773        "SELECT 1 AS hit FROM {membership_tbl} \
774         WHERE user_id = $1::uuid AND organization_id = $2::uuid LIMIT 1"
775    );
776    let rows = state
777        .db
778        .raw_json(
779            &sql,
780            &[
781                Value::String(user_id.to_string()),
782                Value::String(org.to_string()),
783            ],
784        )
785        .await
786        .map_err(db_error)?;
787    Ok(rows.as_array().is_some_and(|rows| !rows.is_empty()))
788}
789
790/// Put `user_id` in `org` with `role`, unless they are in it already.
791///
792/// Written straight rather than through `POST <base>/membership`, which the
793/// person accepting could not call: they are not a member yet, which is the
794/// whole point.
795async fn ensure_membership(
796    state: &AppState,
797    user_id: Uuid,
798    org: Uuid,
799    role: Option<&str>,
800) -> Result<(), HttpResponse> {
801    let Some(membership_r) = state.app.resources.get("membership") else {
802        return Err(error(500, "no membership resource"));
803    };
804    let Some(membership_tbl) = table(state, "membership") else {
805        return Err(error(500, "no membership resource"));
806    };
807
808    let sql = format!(
809        "SELECT 1 AS hit FROM {membership_tbl} \
810         WHERE user_id = $1::uuid AND organization_id = $2::uuid LIMIT 1"
811    );
812    let rows = state
813        .db
814        .raw_json(
815            &sql,
816            &[
817                Value::String(user_id.to_string()),
818                Value::String(org.to_string()),
819            ],
820        )
821        .await
822        .map_err(db_error)?;
823    if rows.as_array().is_some_and(|rows| !rows.is_empty()) {
824        return Ok(());
825    }
826
827    let mut data = Map::new();
828    data.insert("user_id".into(), Value::String(user_id.to_string()));
829    data.insert("organization_id".into(), Value::String(org.to_string()));
830    if let Some(role) = role.filter(|role| !role.is_empty()) {
831        data.insert("role".into(), Value::String(role.to_string()));
832    }
833    state
834        .db
835        .create(membership_r, &data)
836        .await
837        .map_err(db_error)
838        .map(|_| ())
839}
840
841/// The organisation's own name, for the sentence in the email. Falls back to
842/// the app's name rather than to an id, which would mean nothing to a reader.
843async fn organization_name(state: &AppState, org: Uuid) -> String {
844    let fallback = || state.app.display_name();
845    let Some(org_tbl) = table(state, "organization") else {
846        return fallback();
847    };
848    let sql = format!("SELECT name FROM {org_tbl} WHERE id = $1::uuid LIMIT 1");
849    let rows = match state
850        .db
851        .raw_json(&sql, &[Value::String(org.to_string())])
852        .await
853    {
854        Ok(rows) => rows,
855        Err(_) => return fallback(),
856    };
857    rows.as_array()
858        .and_then(|rows| rows.first())
859        .and_then(|row| row.get("name"))
860        .and_then(|v| v.as_str())
861        .filter(|name| !name.is_empty())
862        .map(str::to_owned)
863        .unwrap_or_else(fallback)
864}
865
866/// How to refer to the person who sent an invitation: their display name if the
867/// app keeps one, otherwise the address they signed in with, otherwise nobody.
868async fn inviter_name(state: &AppState, user_id: Uuid) -> Option<String> {
869    let spec = auth_spec(state);
870    let user_r = state.app.resources.get("user")?;
871    let user_tbl = table(state, "user")?;
872
873    let mut candidates: Vec<String> = Vec::new();
874    for name in ["display_name", "name"] {
875        if user_r.fields.contains_key(name) {
876            candidates.push(quote(name));
877        }
878    }
879    candidates.push(quote(&spec.identity_field));
880
881    let sql = format!(
882        "SELECT coalesce({}) AS who FROM {user_tbl} WHERE id = $1::uuid LIMIT 1",
883        candidates.join(", "),
884    );
885    let rows = state
886        .db
887        .raw_json(&sql, &[Value::String(user_id.to_string())])
888        .await
889        .ok()?;
890    rows.as_array()?
891        .first()?
892        .get("who")?
893        .as_str()
894        .filter(|who| !who.is_empty())
895        .map(str::to_owned)
896}
897
898/// Hand a composed message to the app's mailer.
899///
900/// The `Err` is a `502`, not a `500`: the request was fine and this server is
901/// fine — somebody else's service said no, and that is worth distinguishing in
902/// whatever is reading the logs.
903async fn send(state: &AppState, message: apiplant_email::Message) -> Result<(), HttpResponse> {
904    let Some(mailer) = &state.mailer else {
905        // Unreachable through a route, which is only mounted with a mailer —
906        // but a wrong 502 beats a panic if that ever stops being true.
907        return Err(error(502, "this server cannot send email"));
908    };
909    match mailer.send(&message).await {
910        Ok(_) => Ok(()),
911        Err(e) => {
912            tracing::error!(error = %e, "could not send an email");
913            Err(error(502, "could not send the email — try again shortly"))
914        }
915    }
916}
917
918/// `202` with a message that says nothing about who exists.
919fn accepted(message: &str) -> HttpResponse {
920    HttpResponse::Accepted().json(&json!({ "message": message }))
921}
922
923/// An RFC 3339 timestamp `secs` from now, in the form
924/// [`json_to_sql`](apiplant_db) wants for a `timestamp` column.
925fn rfc3339_in(secs: i64) -> String {
926    let at: DateTime<Utc> = Utc::now() + Duration::seconds(secs);
927    at.to_rfc3339()
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933    use apiplant_core::Access;
934
935    fn principal(org: Uuid, role: &str) -> apiplant_auth::Principal {
936        apiplant_auth::Principal {
937            user_id: Uuid::new_v4(),
938            organizations: vec![apiplant_auth::OrgMembership::new(
939                org,
940                Some(role.to_string()),
941                [],
942            )],
943        }
944    }
945
946    #[test]
947    fn who_may_invite_follows_who_may_add_a_member() {
948        let org = Uuid::new_v4();
949
950        // The default: admins of the organisation, and nobody else in it.
951        let admins = Access::Role("admin".into());
952        assert!(invite_policy(Some(&admins), &principal(org, "admin"), org));
953        assert!(!invite_policy(
954            Some(&admins),
955            &principal(org, "member"),
956            org
957        ));
958
959        // An app that lets any member add people gets invitations to match,
960        // rather than a second, stricter answer to the same question.
961        let members = Access::Member;
962        assert!(invite_policy(
963            Some(&members),
964            &principal(org, "member"),
965            org
966        ));
967
968        // …but never for an organisation you are not in.
969        assert!(!invite_policy(
970            Some(&members),
971            &principal(org, "member"),
972            Uuid::new_v4()
973        ));
974    }
975
976    #[test]
977    fn a_policy_that_is_not_a_role_check_falls_back_to_admin() {
978        let org = Uuid::new_v4();
979        // Handing out membership of an organisation is not something to open
980        // up because a permission happened to say `public`, and a model with
981        // no `membership` at all is not an invitation to improvise.
982        for policy in [Some(&Access::Public), None] {
983            assert!(!invite_policy(policy, &principal(org, "member"), org));
984            assert!(invite_policy(policy, &principal(org, "admin"), org));
985        }
986    }
987}