Skip to main content

haste_server/auth_n/
invitations.rs

1//! Users a tenant creates for someone else. Each is an invitation until the
2//! person proves they own the address and accepts. Acceptance sets
3//! `emailVerified`, which login requires and clients cannot set.
4
5use crate::{
6    auth_n::email::send_invitation_email,
7    config::ServerConfig,
8    fhir_client::{FHIRServerClient, ServerCTX},
9    services::ServerState,
10};
11use email_address::EmailAddress;
12use haste_fhir_client::FHIRClient;
13use haste_fhir_model::r4::generated::{
14    resources::{Resource, ResourceType, User as UserResource},
15    terminology::IssueType,
16};
17use haste_fhir_operation_error::OperationOutcomeError;
18use haste_fhir_search::SearchEngine;
19use haste_fhir_terminology::FHIRTerminology;
20use haste_jwt::{ProjectId, TenantId};
21use haste_repository::{
22    Repository,
23    admin::{ProjectModelAdmin, TenantModelAdmin},
24    types::{
25        authorization_code::{AuthorizationCodeKind, CreateAuthorizationCode},
26        tenant::CreateTenant,
27        user::{CreateUser, UpdateUser, User},
28    },
29};
30use std::{str::FromStr, sync::Arc, time::Duration};
31
32/// How long an invitation link works.
33pub const INVITATION_VALID_FOR: Duration = Duration::from_secs(7 * 24 * 60 * 60);
34
35/// `emailVerified`, or false when absent.
36pub fn resource_email_verified(user: &UserResource) -> bool {
37    user.emailVerified
38        .as_ref()
39        .and_then(|verified| verified.value)
40        .unwrap_or(false)
41}
42
43/// Sets `emailVerified`, keeping the element's other fields.
44pub fn set_resource_email_verified(user: &mut UserResource, verified: bool) {
45    user.emailVerified.get_or_insert_default().value = Some(verified);
46}
47
48/// The tenant's display name, or its id if it has none.
49pub async fn tenant_display_name<Repo: Repository>(repo: &Repo, tenant: &TenantId) -> String {
50    TenantModelAdmin::<CreateTenant, _, _, _, _>::read(
51        repo,
52        &TenantId::System,
53        &tenant.as_ref().to_string(),
54    )
55    .await
56    .ok()
57    .flatten()
58    .and_then(|tenant| tenant.display_name)
59    .unwrap_or_else(|| tenant.as_ref().to_string())
60}
61
62async fn email_invitation<Repo: Repository>(
63    repo: &Repo,
64    config: &ServerConfig,
65    tenant: &TenantId,
66    user_id: &str,
67    to: &EmailAddress,
68) -> Result<(), OperationOutcomeError> {
69    let code = ProjectModelAdmin::create(
70        repo,
71        tenant,
72        &ProjectId::System,
73        CreateAuthorizationCode {
74            membership: None,
75            expires_in: INVITATION_VALID_FOR,
76            kind: AuthorizationCodeKind::Invitation,
77            user_id: user_id.to_string(),
78            client_id: None,
79            pkce_code_challenge: None,
80            pkce_code_challenge_method: None,
81            redirect_uri: None,
82            meta: None,
83        },
84    )
85    .await?;
86
87    let tenant_name = tenant_display_name(repo, tenant).await;
88
89    send_invitation_email(
90        config,
91        tenant,
92        &tenant_name,
93        to,
94        &code.code,
95        INVITATION_VALID_FOR,
96    )
97    .await
98}
99
100/// Emails a new user a link to accept or decline. Failures are only logged:
101/// the user can still accept by logging in.
102pub async fn send_invitation<Repo: Repository>(
103    repo: &Repo,
104    config: &ServerConfig,
105    tenant: &TenantId,
106    user: &UserResource,
107) {
108    let Some(user_id) = user.id.as_deref() else {
109        return;
110    };
111    let Some(email) = user.email.as_ref().and_then(|email| email.value.as_deref()) else {
112        return;
113    };
114    let Ok(to) = EmailAddress::from_str(email) else {
115        tracing::warn!(tenant = %tenant, user = user_id, "invited user has no valid email address");
116        return;
117    };
118
119    if let Err(error) = email_invitation(repo, config, tenant, user_id, &to).await {
120        tracing::warn!(tenant = %tenant, user = user_id, ?error, "invitation email not sent");
121    }
122}
123
124/// The user an unexpired invitation `code` is for.
125pub async fn find_invitation<Repo: Repository>(
126    repo: &Repo,
127    tenant: &TenantId,
128    code: &str,
129) -> Result<Option<User>, OperationOutcomeError> {
130    let Some(code) = ProjectModelAdmin::<CreateAuthorizationCode, _, _, _, _>::read(
131        repo,
132        tenant,
133        &ProjectId::System,
134        &code.to_string(),
135    )
136    .await?
137    else {
138        return Ok(None);
139    };
140
141    if code.kind != AuthorizationCodeKind::Invitation || code.is_expired.unwrap_or(true) {
142        return Ok(None);
143    }
144
145    TenantModelAdmin::<CreateUser, _, _, _, _>::read(repo, tenant, &code.user_id).await
146}
147
148/// Spends an invitation code.
149pub async fn delete_invitation_code<Repo: Repository>(
150    repo: &Repo,
151    tenant: &TenantId,
152    code: &str,
153) -> Result<(), OperationOutcomeError> {
154    ProjectModelAdmin::<CreateAuthorizationCode, _, _, _, _>::delete(
155        repo,
156        tenant,
157        &ProjectId::System,
158        &code.to_string(),
159    )
160    .await
161}
162
163fn system_ctx<
164    Repo: Repository + Send + Sync + 'static,
165    Search: SearchEngine + Send + Sync + 'static,
166    Terminology: FHIRTerminology + Send + Sync + 'static,
167>(
168    state: &ServerState<Repo, Search, Terminology>,
169    tenant: &TenantId,
170) -> Arc<ServerCTX<FHIRServerClient<Repo, Search, Terminology>>> {
171    Arc::new(ServerCTX::system(
172        tenant.clone(),
173        ProjectId::System,
174        state.fhir_client.clone(),
175        state.rate_limit.clone(),
176    ))
177}
178
179/// Accepts the user: sets `emailVerified` on the `User` resource so the row
180/// follows, or on the row alone if there is no resource.
181pub async fn accept<
182    Repo: Repository + Send + Sync + 'static,
183    Search: SearchEngine + Send + Sync + 'static,
184    Terminology: FHIRTerminology + Send + Sync + 'static,
185>(
186    state: &ServerState<Repo, Search, Terminology>,
187    user: User,
188) -> Result<User, OperationOutcomeError> {
189    if user.email_verified {
190        return Ok(user);
191    }
192
193    let ctx = system_ctx(state, &user.tenant);
194
195    match state
196        .fhir_client
197        .read(ctx.clone(), ResourceType::User, user.id.clone())
198        .await?
199    {
200        Some(Resource::User(mut resource)) => {
201            set_resource_email_verified(&mut resource, true);
202            state
203                .fhir_client
204                .update(
205                    ctx,
206                    ResourceType::User,
207                    user.id.clone(),
208                    Resource::User(resource),
209                )
210                .await?;
211        }
212        _ => {
213            TenantModelAdmin::<CreateUser, _, _, _, String>::update(
214                state.repo.as_ref(),
215                &user.tenant,
216                UpdateUser {
217                    id: user.id.clone(),
218                    email: None,
219                    role: None,
220                    method: None,
221                    provider_id: None,
222                    password: None,
223                    email_verified: Some(true),
224                },
225            )
226            .await?;
227        }
228    }
229
230    Ok(User {
231        email_verified: true,
232        ..user
233    })
234}
235
236/// Deletes an unaccepted user, through its `User` resource if it has one.
237/// Its memberships cascade with the row.
238pub async fn decline<
239    Repo: Repository + Send + Sync + 'static,
240    Search: SearchEngine + Send + Sync + 'static,
241    Terminology: FHIRTerminology + Send + Sync + 'static,
242>(
243    state: &ServerState<Repo, Search, Terminology>,
244    user: &User,
245) -> Result<(), OperationOutcomeError> {
246    if user.email_verified {
247        return Err(OperationOutcomeError::error(
248            IssueType::invalid(),
249            "That account was accepted already. Ask the workspace to remove it.".to_string(),
250        ));
251    }
252
253    let ctx = system_ctx(state, &user.tenant);
254
255    match state
256        .fhir_client
257        .read(ctx.clone(), ResourceType::User, user.id.clone())
258        .await?
259    {
260        Some(Resource::User(_)) => {
261            state
262                .fhir_client
263                .delete_instance(ctx, ResourceType::User, user.id.clone())
264                .await?;
265        }
266        _ => {
267            TenantModelAdmin::<CreateUser, _, _, _, _>::delete(
268                state.repo.as_ref(),
269                &user.tenant,
270                &user.id,
271            )
272            .await?;
273        }
274    }
275
276    Ok(())
277}