Skip to main content

haste_health/commands/
admin.rs

1use clap::{Subcommand, ValueEnum};
2use figment::{
3    Figment,
4    providers::{Env, Format, Toml},
5};
6use haste_fhir_client::FHIRClient;
7use haste_fhir_model::r4::generated::{
8    resources::{
9        AccessPolicyV2, AccessPolicyV2Assignment, Bundle, BundleEntry, BundleEntryRequest,
10        ClientApplication, Resource,
11    },
12    terminology::{
13        AccessPolicyv2Engine, BundleType, ClientapplicationGrantType,
14        ClientapplicationResponseTypes, HttpVerb, IssueType, UserRole,
15    },
16    types::{FHIRString, FHIRUri, Reference},
17};
18use haste_fhir_operation_error::OperationOutcomeError;
19use haste_fhir_search::SearchEngine;
20use haste_jwt::{ProjectId, TenantId, claims::SubscriptionTier};
21use haste_repository::{
22    admin::{Migrate, TenantModelAdmin},
23    types::user::{AuthMethod, CreateUser, UserSearchClauses},
24};
25use haste_server::{
26    auth_n::{
27        invitations,
28        oidc::utilities::{revoke_refresh_tokens, set_user_password},
29    },
30    config::ServerConfig,
31    fhir_client::ServerCTX,
32    load_artifacts::{self, reset_artifacts},
33    services,
34    tenants::{create_tenant, create_user},
35};
36use std::sync::Arc;
37
38/// Subscription tier to assign a newly created tenant.
39#[derive(Clone, Debug, ValueEnum)]
40pub(crate) enum UserSubscriptionChoice {
41    Free,
42    Professional,
43    Team,
44    Unlimited,
45}
46
47impl From<UserSubscriptionChoice> for SubscriptionTier {
48    fn from(choice: UserSubscriptionChoice) -> Self {
49        match choice {
50            UserSubscriptionChoice::Free => SubscriptionTier::Free,
51            UserSubscriptionChoice::Professional => SubscriptionTier::Professional,
52            UserSubscriptionChoice::Team => SubscriptionTier::Team,
53            UserSubscriptionChoice::Unlimited => SubscriptionTier::Unlimited,
54        }
55    }
56}
57
58/// How a newly created OIDC client authenticates.
59#[derive(Clone, Debug, ValueEnum, PartialEq, Eq)]
60pub(crate) enum ClientGrantTypeChoice {
61    /// A confidential (server-to-server) client authenticated with a client secret.
62    ClientCredentials,
63    /// A public client (no secret) a human logs into via the browser (authorization_code + PKCE).
64    AuthorizationCode,
65}
66
67/// Manage OIDC ClientApplication resources.
68#[derive(Subcommand, Debug)]
69pub(crate) enum ClientCommands {
70    /// Create a ClientApplication and, for client-credentials clients, an AccessPolicyV2
71    /// granting it full access.
72    Create {
73        /// OIDC client ID to create.
74        #[arg(short, long)]
75        id: String,
76        /// Required for --grant-type client-credentials. Ignored (and unset, making the
77        /// client public) for --grant-type authorization-code.
78        #[arg(short, long)]
79        secret: Option<String>,
80        /// Tenant to create the client in.
81        #[arg(short, long)]
82        tenant: String,
83        /// Project to create the client in.
84        #[arg(short, long)]
85        project: String,
86        /// OAuth grant type the client uses to authenticate.
87        #[arg(long, value_enum, default_value = "client-credentials")]
88        grant_type: ClientGrantTypeChoice,
89        /// Loopback redirect URI(s) to allow, e.g. http://127.0.0.1:8976/callback.
90        /// Required for --grant-type authorization-code.
91        #[arg(long)]
92        redirect_uri: Vec<String>,
93        /// OAuth scope to grant the client. Defaults depend on --grant-type.
94        #[arg(long)]
95        scope: Option<String>,
96    },
97}
98
99/// Server-side administrative operations (tenants, users, clients, migrations).
100#[derive(Subcommand, Debug)]
101pub(crate) enum AdminCommands {
102    /// Manage tenants.
103    Tenant {
104        #[command(subcommand)]
105        command: TenantCommands,
106    },
107
108    /// Manage users.
109    User {
110        #[command(subcommand)]
111        command: UserCommands,
112    },
113
114    /// Manage OIDC ClientApplication resources.
115    Client {
116        #[command(subcommand)]
117        command: ClientCommands,
118    },
119
120    /// Run database/search/artifact migrations.
121    Migrate {
122        #[command(subcommand)]
123        command: MigrationCommands,
124    },
125}
126
127/// Run database/search/artifact migrations.
128#[derive(Subcommand, Debug)]
129pub(crate) enum MigrationCommands {
130    /// Load the built-in FHIR artifacts (StructureDefinitions, ValueSets, etc).
131    Artifacts {},
132    /// Reload the built-in FHIR artifacts from scratch, discarding local edits to them.
133    ResetArtifacts {},
134    /// Run pending repository (Postgres) migrations.
135    Repo {},
136    /// Run pending search index (Postgres or Elasticsearch) migrations.
137    Search {},
138    /// Run all of the above: repo, then search, then artifacts.
139    All,
140}
141
142/// Manage tenants.
143#[derive(Subcommand, Debug)]
144pub(crate) enum TenantCommands {
145    /// Create a tenant and its owner user.
146    Create {
147        /// Tenant ID to create.
148        #[arg(short, long)]
149        id: String,
150        /// Subscription tier to assign. Defaults to Free.
151        #[arg(short, long)]
152        subscription_tier: Option<UserSubscriptionChoice>,
153        /// Email address for the tenant's owner user.
154        #[arg(long)]
155        owner_email: String,
156        /// Password for the tenant's owner user.
157        #[arg(long)]
158        owner_password: String,
159    },
160}
161
162/// Manage users.
163#[derive(Subcommand, Debug)]
164pub(crate) enum UserCommands {
165    /// Create an admin user within a tenant.
166    Create {
167        /// Email address for the new user.
168        #[arg(short, long)]
169        email: String,
170        /// Password for the new user.
171        #[arg(short, long)]
172        password: String,
173        /// Tenant to create the user in.
174        #[arg(short, long)]
175        tenant: String,
176    },
177    /// Set a user's password, accept their invitation and revoke their refresh tokens.
178    SetPassword {
179        /// Email address of the user.
180        #[arg(short, long)]
181        email: String,
182        /// New password for the user.
183        #[arg(short, long)]
184        password: String,
185        /// Tenant the user belongs to.
186        #[arg(short, long)]
187        tenant: String,
188    },
189}
190
191async fn migrate_repo(config: Arc<ServerConfig>) -> Result<(), OperationOutcomeError> {
192    let services = services::create_services(config).await?;
193    services.repo.migrate().await?;
194    Ok(())
195}
196
197async fn migrate_search(config: Arc<ServerConfig>) -> Result<(), OperationOutcomeError> {
198    let services = services::create_services(config).await?;
199    services
200        .search
201        .migrate(&haste_repository::types::SupportedFHIRVersions::R4)
202        .await?;
203    Ok(())
204}
205
206async fn migrate_artifacts(config: Arc<ServerConfig>) -> Result<(), OperationOutcomeError> {
207    let mut config = (*config).clone();
208    config.allow_artifact_mutations = true;
209
210    load_artifacts::load_artifacts(Arc::new(config)).await?;
211
212    Ok(())
213}
214
215/// Runs the `admin` command group.
216pub(crate) async fn run(command: &AdminCommands) -> Result<(), OperationOutcomeError> {
217    let config: Arc<ServerConfig> = Arc::new(
218        Figment::new()
219            .merge(Toml::file("haste.toml"))
220            .merge(Env::prefixed("HASTE_"))
221            .extract()
222            .map_err(|e| OperationOutcomeError::error(IssueType::exception(), e.to_string()))?,
223    );
224
225    match &command {
226        AdminCommands::Migrate { command } => match command {
227            MigrationCommands::Artifacts {} => migrate_artifacts(config).await,
228            MigrationCommands::ResetArtifacts {} => reset_artifacts(config).await,
229            MigrationCommands::Repo {} => migrate_repo(config).await,
230            MigrationCommands::Search {} => migrate_search(config).await,
231            MigrationCommands::All => {
232                migrate_search(config.clone()).await?;
233                migrate_repo(config.clone()).await?;
234                migrate_artifacts(config).await?;
235                Ok(())
236            }
237        },
238        AdminCommands::Tenant { command } => match command {
239            TenantCommands::Create {
240                id,
241                subscription_tier,
242                owner_email,
243                owner_password,
244            } => {
245                let services = services::create_services(config).await?;
246                let result = create_tenant(
247                    services.as_ref(),
248                    Some(id.clone()),
249                    &SubscriptionTier::from(
250                        subscription_tier
251                            .clone()
252                            .unwrap_or(UserSubscriptionChoice::Free),
253                    ),
254                    haste_fhir_model::r4::generated::resources::User {
255                        role: UserRole::owner(),
256                        email: Some(Box::new(
257                            haste_fhir_model::r4::generated::types::FHIRString {
258                                value: Some(owner_email.clone()),
259                                ..Default::default()
260                            },
261                        )),
262                        ..Default::default()
263                    },
264                    Some(owner_password),
265                )
266                .await;
267
268                if let Err(operation_outcome_error) = result.as_ref()
269                    && let Some(issue) = operation_outcome_error.outcome().issue.first()
270                    && issue.code == IssueType::duplicate()
271                {
272                    println!("Tenant with ID '{}' already exists.", id);
273                    return Ok(());
274                }
275
276                result?;
277
278                Ok(())
279            }
280        },
281        AdminCommands::User { command } => match command {
282            UserCommands::Create {
283                email,
284                password,
285                tenant,
286            } => {
287                let services = services::create_services(config)
288                    .await?
289                    .transaction()
290                    .await?;
291
292                let tenant = TenantId::new(tenant.clone());
293
294                create_user(
295                    &services,
296                    &tenant,
297                    haste_fhir_model::r4::generated::resources::User {
298                        role: UserRole::admin(),
299                        email: Some(Box::new(
300                            haste_fhir_model::r4::generated::types::FHIRString {
301                                value: Some(email.clone()),
302                                ..Default::default()
303                            },
304                        )),
305                        ..Default::default()
306                    },
307                    Some(password),
308                )
309                .await?;
310
311                services.commit().await?;
312
313                Ok(())
314            }
315            UserCommands::SetPassword {
316                email,
317                password,
318                tenant,
319            } => {
320                let services = services::create_services(config)
321                    .await?
322                    .transaction()
323                    .await?;
324
325                let tenant = TenantId::new(tenant.clone());
326
327                let Some(user) = TenantModelAdmin::<CreateUser, _, _, _, String>::search(
328                    services.repo.as_ref(),
329                    &tenant,
330                    &UserSearchClauses {
331                        email: Some(email.clone()),
332                        role: None,
333                        method: Some(AuthMethod::EmailPassword),
334                    },
335                )
336                .await?
337                .into_iter()
338                .next() else {
339                    return Err(OperationOutcomeError::error(
340                        IssueType::not_found(),
341                        format!(
342                            "No email/password user with email '{}' in tenant '{}'.",
343                            email, tenant
344                        ),
345                    ));
346                };
347
348                set_user_password(services.repo.as_ref(), &tenant, email, &user.id, password)
349                    .await?;
350                revoke_refresh_tokens(services.repo.as_ref(), &tenant, &user.id).await?;
351                // Sign-in requires an accepted (email-verified) user.
352                invitations::accept(&services, user).await?;
353
354                services.commit().await?;
355
356                Ok(())
357            }
358        },
359        AdminCommands::Client { command } => match command {
360            ClientCommands::Create {
361                tenant,
362                project,
363                id,
364                secret,
365                grant_type,
366                redirect_uri,
367                scope,
368            } => {
369                let client_app = match grant_type {
370                    ClientGrantTypeChoice::ClientCredentials => {
371                        let Some(secret) = secret else {
372                            return Err(OperationOutcomeError::error(
373                                IssueType::invalid(),
374                                "--secret is required for --grant-type client-credentials"
375                                    .to_string(),
376                            ));
377                        };
378
379                        ClientApplication {
380                            id: Some(id.clone()),
381                            secret: Some(Box::new(FHIRString {
382                                value: Some(secret.clone()),
383                                ..Default::default()
384                            })),
385                            scope: Some(Box::new(FHIRString {
386                                value: Some(
387                                    scope.clone().unwrap_or("openid system/*.*".to_string()),
388                                ),
389                                ..Default::default()
390                            })),
391                            name: Box::new(FHIRString {
392                                value: Some("CLI".to_string()),
393                                ..Default::default()
394                            }),
395                            grantType: vec![ClientapplicationGrantType::client_credentials()],
396                            responseTypes: ClientapplicationResponseTypes::token(),
397                            ..Default::default()
398                        }
399                    }
400                    ClientGrantTypeChoice::AuthorizationCode => {
401                        if redirect_uri.is_empty() {
402                            return Err(OperationOutcomeError::error(
403                                IssueType::invalid(),
404                                "At least one --redirect-uri is required for --grant-type authorization-code"
405                                    .to_string(),
406                            ));
407                        }
408
409                        ClientApplication {
410                            id: Some(id.clone()),
411                            secret: None,
412                            scope: Some(Box::new(FHIRString {
413                                value: Some(scope.clone().unwrap_or(
414                                    "openid profile fhirUser offline_access user/*.*".to_string(),
415                                )),
416                                ..Default::default()
417                            })),
418                            name: Box::new(FHIRString {
419                                value: Some("CLI".to_string()),
420                                ..Default::default()
421                            }),
422                            grantType: vec![
423                                ClientapplicationGrantType::authorization_code(),
424                                ClientapplicationGrantType::refresh_token(),
425                            ],
426                            responseTypes: ClientapplicationResponseTypes::code(),
427                            redirectUri: Some(
428                                redirect_uri
429                                    .iter()
430                                    .map(|uri| FHIRString {
431                                        value: Some(uri.clone()),
432                                        ..Default::default()
433                                    })
434                                    .collect(),
435                            ),
436                            ..Default::default()
437                        }
438                    }
439                };
440
441                let services = services::create_services(config).await?;
442
443                let ctx = Arc::new(ServerCTX::system(
444                    TenantId::new(tenant.clone()),
445                    ProjectId::new(project.clone()),
446                    services.fhir_client.clone(),
447                    services.rate_limit.clone(),
448                ));
449
450                let mut entries = Vec::with_capacity(3);
451
452                // Authorization-code clients are used by humans and rely on whatever
453                // access policy is attached to the authenticating user, so only
454                // client-credentials clients get an access policy of their own.
455                if *grant_type == ClientGrantTypeChoice::ClientCredentials {
456                    entries.push(BundleEntry {
457                        fullUrl: Some(Box::new(FHIRUri {
458                            value: Some("access-policy".to_string()),
459                            ..Default::default()
460                        })),
461                        request: Some(BundleEntryRequest {
462                            method: HttpVerb::post(),
463                            url: Box::new(FHIRUri {
464                                value: Some("AccessPolicyV2".to_string()),
465                                ..Default::default()
466                            }),
467                            ..Default::default()
468                        }),
469                        resource: Some(Box::new(Resource::AccessPolicyV2(AccessPolicyV2 {
470                            name: Box::new(FHIRString {
471                                value: Some("ADMIN".to_string()),
472                                ..Default::default()
473                            }),
474                            engine: AccessPolicyv2Engine::full_access(),
475                            ..Default::default()
476                        }))),
477                        ..Default::default()
478                    });
479                    entries.push(BundleEntry {
480                        request: Some(BundleEntryRequest {
481                            method: HttpVerb::post(),
482                            url: Box::new(FHIRUri {
483                                value: Some("AccessPolicyV2Assignment".to_string()),
484                                ..Default::default()
485                            }),
486                            ..Default::default()
487                        }),
488                        resource: Some(Box::new(Resource::AccessPolicyV2Assignment(
489                            AccessPolicyV2Assignment {
490                                accessPolicy: Box::new(Reference {
491                                    reference: Some(Box::new(FHIRString {
492                                        value: Some("access-policy".to_string()),
493                                        ..Default::default()
494                                    })),
495                                    ..Default::default()
496                                }),
497                                link: Box::new(Reference {
498                                    reference: Some(Box::new(FHIRString {
499                                        value: Some("client-app".to_string()),
500                                        ..Default::default()
501                                    })),
502                                    ..Default::default()
503                                }),
504                                ..Default::default()
505                            },
506                        ))),
507                        ..Default::default()
508                    });
509                }
510
511                entries.push(BundleEntry {
512                    fullUrl: Some(Box::new(FHIRUri {
513                        value: Some("client-app".to_string()),
514                        ..Default::default()
515                    })),
516                    request: Some(BundleEntryRequest {
517                        method: HttpVerb::put(),
518                        url: Box::new(FHIRUri {
519                            value: Some(format!("ClientApplication/{}", id)),
520                            ..Default::default()
521                        }),
522                        ..Default::default()
523                    }),
524                    resource: Some(Box::new(Resource::ClientApplication(client_app))),
525                    ..Default::default()
526                });
527
528                let transaction_bundle = Bundle {
529                    type_: BundleType::transaction(),
530                    entry: Some(entries),
531                    ..Default::default()
532                };
533
534                services
535                    .fhir_client
536                    .transaction(ctx, transaction_bundle)
537                    .await?;
538
539                Ok(())
540            }
541        },
542    }
543}