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#[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#[derive(Clone, Debug, ValueEnum, PartialEq, Eq)]
60pub(crate) enum ClientGrantTypeChoice {
61 ClientCredentials,
63 AuthorizationCode,
65}
66
67#[derive(Subcommand, Debug)]
69pub(crate) enum ClientCommands {
70 Create {
73 #[arg(short, long)]
75 id: String,
76 #[arg(short, long)]
79 secret: Option<String>,
80 #[arg(short, long)]
82 tenant: String,
83 #[arg(short, long)]
85 project: String,
86 #[arg(long, value_enum, default_value = "client-credentials")]
88 grant_type: ClientGrantTypeChoice,
89 #[arg(long)]
92 redirect_uri: Vec<String>,
93 #[arg(long)]
95 scope: Option<String>,
96 },
97}
98
99#[derive(Subcommand, Debug)]
101pub(crate) enum AdminCommands {
102 Tenant {
104 #[command(subcommand)]
105 command: TenantCommands,
106 },
107
108 User {
110 #[command(subcommand)]
111 command: UserCommands,
112 },
113
114 Client {
116 #[command(subcommand)]
117 command: ClientCommands,
118 },
119
120 Migrate {
122 #[command(subcommand)]
123 command: MigrationCommands,
124 },
125}
126
127#[derive(Subcommand, Debug)]
129pub(crate) enum MigrationCommands {
130 Artifacts {},
132 ResetArtifacts {},
134 Repo {},
136 Search {},
138 All,
140}
141
142#[derive(Subcommand, Debug)]
144pub(crate) enum TenantCommands {
145 Create {
147 #[arg(short, long)]
149 id: String,
150 #[arg(short, long)]
152 subscription_tier: Option<UserSubscriptionChoice>,
153 #[arg(long)]
155 owner_email: String,
156 #[arg(long)]
158 owner_password: String,
159 },
160}
161
162#[derive(Subcommand, Debug)]
164pub(crate) enum UserCommands {
165 Create {
167 #[arg(short, long)]
169 email: String,
170 #[arg(short, long)]
172 password: String,
173 #[arg(short, long)]
175 tenant: String,
176 },
177 SetPassword {
179 #[arg(short, long)]
181 email: String,
182 #[arg(short, long)]
184 password: String,
185 #[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
215pub(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 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 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}