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