Skip to main content

haste_server/
tenants.rs

1use crate::{
2    auth_n::{invitations::set_resource_email_verified, oidc::utilities::set_user_password},
3    fhir_client::ServerCTX,
4    services::ServerState,
5    ui::components::TenantName,
6};
7use haste_fhir_client::FHIRClient;
8use haste_fhir_model::r4::generated::{
9    resources::{Project, Resource, ResourceType, User},
10    terminology::{IssueType, SupportedFhirVersion, UserRole},
11    types::FHIRString,
12};
13use haste_fhir_operation_error::OperationOutcomeError;
14use haste_fhir_search::SearchEngine;
15use haste_fhir_terminology::FHIRTerminology;
16use haste_jwt::{ProjectId, TenantId, claims::SubscriptionTier};
17use haste_repository::{
18    Repository,
19    admin::TenantModelAdmin,
20    types::{
21        tenant::{CreateTenant, Tenant},
22        user::CreateUser,
23    },
24    utilities::{HOSTNAME_ID_MIN_LEN, generate_hostname_id, validate_hostname_id},
25};
26use std::sync::Arc;
27
28pub async fn create_user<
29    Repo: Repository + Send + Sync + 'static,
30    Search: SearchEngine + Send + Sync + 'static,
31    Terminology: FHIRTerminology + Send + Sync + 'static,
32>(
33    services: &ServerState<Repo, Search, Terminology>,
34    tenant: &TenantId,
35    mut user_resource: User,
36    password: Option<&str>,
37) -> Result<User, OperationOutcomeError> {
38    // Owners and users given a password are accepted already. Anyone else
39    // is an invitation.
40    if password.is_some() || user_resource.role == UserRole::owner() {
41        set_resource_email_verified(&mut user_resource, true);
42    }
43
44    let ctx = Arc::new(ServerCTX::system(
45        tenant.clone(),
46        ProjectId::System,
47        services.fhir_client.clone(),
48        services.rate_limit.clone(),
49    ));
50
51    let user = services
52        .fhir_client
53        .create(ctx, ResourceType::User, Resource::User(user_resource))
54        .await?;
55
56    let user = match user {
57        Resource::User(user) => user,
58        _ => panic!("Created resource is not a User"),
59    };
60
61    let user_id = user.id.clone().unwrap();
62
63    if let Some(password) = password {
64        set_user_password(
65            &*services.repo,
66            tenant,
67            &user
68                .email
69                .as_ref()
70                .and_then(|e| e.value.as_ref())
71                .map(|s| s.to_string())
72                .unwrap_or_default(),
73            &user_id,
74            password,
75        )
76        .await?;
77    }
78
79    Ok(user)
80}
81
82pub struct CreateTenantOutput {
83    pub tenant: Tenant,
84    pub owner: haste_repository::types::user::User,
85}
86
87pub async fn read_tenant<
88    Repo: Repository + Send + Sync + 'static,
89    Search: SearchEngine + Send + Sync + 'static,
90    Terminology: FHIRTerminology + Send + Sync + 'static,
91>(
92    services: &ServerState<Repo, Search, Terminology>,
93    tenant_id: &TenantId,
94) -> Result<Tenant, OperationOutcomeError> {
95    TenantModelAdmin::<CreateTenant, _, _, _, _>::read(
96        services.repo.as_ref(),
97        &TenantId::System,
98        &tenant_id.as_ref().to_string(),
99    )
100    .await?
101    .ok_or_else(|| {
102        OperationOutcomeError::error(
103            IssueType::not_found(),
104            format!("Tenant '{}' was not found.", tenant_id),
105        )
106    })
107}
108
109pub fn tenant_name(tenant: &Tenant) -> TenantName {
110    TenantName(tenant.display_name.clone())
111}
112
113pub const TENANT_ID_MIN_LEN: usize = HOSTNAME_ID_MIN_LEN;
114pub const TENANT_ID_MAX_LEN: usize = 32;
115
116/// Names that would read as something else in a URL path or an admin app
117/// hostname. The system tenant is refused separately, by type.
118const RESERVED_TENANT_IDS: &[&str] = &[
119    "admin",
120    "api",
121    "auth",
122    "www",
123    "haste",
124    "haste-health",
125    "hastehealth",
126    "login",
127    "signup",
128    "support",
129    "help",
130];
131
132/// Domains that say nothing about an organisation.
133const PUBLIC_MAIL_DOMAINS: &[&str] = &[
134    "gmail.com",
135    "googlemail.com",
136    "outlook.com",
137    "hotmail.com",
138    "live.com",
139    "msn.com",
140    "yahoo.com",
141    "ymail.com",
142    "icloud.com",
143    "me.com",
144    "mac.com",
145    "aol.com",
146    "proton.me",
147    "protonmail.com",
148    "pm.me",
149    "mail.com",
150    "gmx.com",
151    "gmx.net",
152    "fastmail.com",
153    "hey.com",
154    "yandex.com",
155    "zoho.com",
156];
157
158/// Checks an id chosen for a new tenant. The length cap leaves a project id
159/// room in the hostname label the two share. Refuses the system tenant and
160/// the reserved names.
161pub fn validate_tenant_id(id: &str) -> Result<TenantId, OperationOutcomeError> {
162    let invalid = |message: String| OperationOutcomeError::error(IssueType::invalid(), message);
163
164    let length = id.chars().count();
165    if !(TENANT_ID_MIN_LEN..=TENANT_ID_MAX_LEN).contains(&length) {
166        return Err(invalid(format!(
167            "A tenant id is {TENANT_ID_MIN_LEN} to {TENANT_ID_MAX_LEN} characters long."
168        )));
169    }
170
171    validate_hostname_id(id)?;
172
173    let tenant = TenantId::new(id.to_string());
174    if tenant == TenantId::System || RESERVED_TENANT_IDS.contains(&id) {
175        return Err(invalid(format!(
176            "'{id}' is reserved. Choose another tenant id."
177        )));
178    }
179
180    Ok(tenant)
181}
182
183fn random_tenant_id() -> TenantId {
184    TenantId::new(generate_hostname_id(Some(16)))
185}
186
187/// A valid tenant id to propose for an email address: the organisation from a
188/// work address, otherwise the mailbox name. It may already be taken.
189pub fn suggested_tenant_id(email: &str) -> String {
190    let (mailbox, domain) = email.rsplit_once('@').unwrap_or((email, ""));
191    let domain = domain.to_ascii_lowercase();
192    let base = if domain.is_empty() || PUBLIC_MAIL_DOMAINS.contains(&domain.as_str()) {
193        mailbox
194    } else {
195        domain.split('.').next().unwrap_or(mailbox)
196    };
197
198    // Runs of anything but ASCII letters and digits become one hyphen.
199    let id = base
200        .to_ascii_lowercase()
201        .split(|character: char| !character.is_ascii_alphanumeric())
202        .filter(|part| !part.is_empty())
203        .collect::<Vec<_>>()
204        .join("-");
205    let id = id[..id.len().min(TENANT_ID_MAX_LEN)].trim_end_matches('-');
206
207    validate_tenant_id(id)
208        .unwrap_or_else(|_| random_tenant_id())
209        .as_ref()
210        .to_string()
211}
212
213/// `base`, a valid tenant id, with a short random suffix, for when `base` is
214/// taken.
215pub fn suggested_tenant_id_variant(base: &str) -> String {
216    const SUFFIX_LEN: usize = 4;
217
218    let base: String = base
219        .chars()
220        .take(TENANT_ID_MAX_LEN - SUFFIX_LEN - 1)
221        .collect();
222    let base = base.trim_end_matches('-');
223
224    format!("{base}-{}", generate_hostname_id(Some(SUFFIX_LEN)))
225}
226
227pub async fn create_tenant<
228    Repo: Repository + Send + Sync + 'static,
229    Search: SearchEngine + Send + Sync + 'static,
230    Terminology: FHIRTerminology + Send + Sync + 'static,
231>(
232    services: &ServerState<Repo, Search, Terminology>,
233    tenant_id: Option<String>,
234    subscription_tier: &SubscriptionTier,
235    owner: haste_fhir_model::r4::generated::resources::User,
236    owner_password: Option<&str>,
237) -> Result<CreateTenantOutput, OperationOutcomeError> {
238    let id = match tenant_id {
239        Some(id) => validate_tenant_id(&id)?,
240        None => random_tenant_id(),
241    };
242
243    let services = services.transaction().await?;
244
245    let new_tenant = TenantModelAdmin::create(
246        &*services.repo,
247        &TenantId::System,
248        CreateTenant {
249            id: Some(id),
250            subscription_tier: Some(subscription_tier.clone().into()),
251            display_name: None,
252            logo_data: None,
253            logo_content_type: None,
254        },
255    )
256    .await?;
257
258    services
259        .fhir_client
260        .create(
261            Arc::new(ServerCTX::system(
262                new_tenant.id.clone(),
263                ProjectId::System,
264                services.fhir_client.clone(),
265                services.rate_limit.clone(),
266            )),
267            ResourceType::Project,
268            Resource::Project(Project {
269                id: Some(ProjectId::System.to_string()),
270                name: Box::new(FHIRString {
271                    value: Some(ProjectId::System.to_string()),
272                    ..Default::default()
273                }),
274                fhirVersion: SupportedFhirVersion::r4(),
275                ..Default::default()
276            }),
277        )
278        .await?;
279
280    let user = create_user(&services, &new_tenant.id, owner, owner_password).await?;
281
282    let Some(user_id) = user.id else {
283        return Err(OperationOutcomeError::fatal(
284            IssueType::invalid(),
285            "The user ID is required to complete the tenant creation process.".to_string(),
286        ));
287    };
288
289    let Some(user) = TenantModelAdmin::<CreateUser, _, _, _, _>::read(
290        services.repo.as_ref(),
291        &new_tenant.id,
292        &user_id,
293    )
294    .await?
295    else {
296        return Err(OperationOutcomeError::fatal(
297            IssueType::invalid(),
298            "The user does not exist after creation.".to_string(),
299        ));
300    };
301
302    services.commit().await?;
303
304    Ok(CreateTenantOutput {
305        tenant: new_tenant,
306        owner: user,
307    })
308}