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#[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#[derive(Clone, Debug, ValueEnum, PartialEq, Eq)]
53pub(crate) enum ClientGrantTypeChoice {
54 ClientCredentials,
56 AuthorizationCode,
58}
59
60#[derive(Subcommand, Debug)]
62pub(crate) enum ClientCommands {
63 Create {
66 #[arg(short, long)]
68 id: String,
69 #[arg(short, long)]
72 secret: Option<String>,
73 #[arg(short, long)]
75 tenant: String,
76 #[arg(short, long)]
78 project: String,
79 #[arg(long, value_enum, default_value = "client-credentials")]
81 grant_type: ClientGrantTypeChoice,
82 #[arg(long)]
85 redirect_uri: Vec<String>,
86 #[arg(long)]
88 scope: Option<String>,
89 },
90}
91
92#[derive(Subcommand, Debug)]
94pub(crate) enum AdminCommands {
95 Tenant {
97 #[command(subcommand)]
98 command: TenantCommands,
99 },
100
101 User {
103 #[command(subcommand)]
104 command: UserCommands,
105 },
106
107 Client {
109 #[command(subcommand)]
110 command: ClientCommands,
111 },
112
113 Migrate {
115 #[command(subcommand)]
116 command: MigrationCommands,
117 },
118}
119
120#[derive(Subcommand, Debug)]
122pub(crate) enum MigrationCommands {
123 Artifacts {},
125 ResetArtifacts {},
127 Repo {},
129 Search {},
131 All,
133}
134
135#[derive(Subcommand, Debug)]
137pub(crate) enum TenantCommands {
138 Create {
140 #[arg(short, long)]
142 id: String,
143 #[arg(short, long)]
145 subscription_tier: Option<UserSubscriptionChoice>,
146 #[arg(long)]
148 owner_email: String,
149 #[arg(long)]
151 owner_password: String,
152 },
153}
154
155#[derive(Subcommand, Debug)]
157pub(crate) enum UserCommands {
158 Create {
160 #[arg(short, long)]
162 email: String,
163 #[arg(short, long)]
165 password: String,
166 #[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
196pub(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 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}