1use clap::Subcommand;
2use serde_json::json;
3
4use crate::util::{
5 admin_surface_enabled, api_request, dry_run_enabled, env_flag_enabled, exit_error,
6 print_json_stdout,
7};
8
9#[derive(Subcommand)]
10pub enum AdminCommands {
11 CreateUser {
13 #[arg(long)]
15 email: String,
16 #[arg(long)]
18 password: String,
19 #[arg(long)]
21 display_name: Option<String>,
22 },
23 CreateKey {
25 #[arg(long)]
27 user_id: String,
28 #[arg(long)]
30 label: String,
31 #[arg(long)]
33 expires_in_days: Option<i64>,
34 },
35 DeleteUser {
37 #[arg(long)]
39 user_id: String,
40 #[arg(long)]
42 confirm: bool,
43 },
44 ListRequests {
46 #[arg(long, default_value = "pending")]
48 status: String,
49 },
50 ApproveRequest {
52 id: String,
54 },
55 RejectRequest {
57 id: String,
59 },
60 CreateInvite {
62 #[arg(long)]
64 email: Option<String>,
65 #[arg(long, default_value = "7")]
67 expires_in_days: i64,
68 },
69 ListInvites {
71 #[arg(long)]
73 status: Option<String>,
74 },
75 SupportReidentify {
77 #[arg(long)]
79 user_id: String,
80 #[arg(long)]
82 reason: String,
83 #[arg(long)]
85 ticket_id: String,
86 #[arg(long)]
88 requested_mode: Option<String>,
89 #[arg(long)]
91 expires_at: Option<String>,
92 },
93 Telemetry {
95 #[command(subcommand)]
96 command: AdminTelemetryCommands,
97 },
98}
99
100#[derive(Subcommand)]
101pub enum AdminTelemetryCommands {
102 Overview {
104 #[arg(long)]
106 window_hours: Option<i32>,
107 },
108 Anomalies {
110 #[arg(long)]
112 window_hours: Option<i32>,
113 #[arg(long)]
115 limit: Option<i64>,
116 },
117 Signals {
119 #[arg(long)]
121 window_hours: Option<i32>,
122 #[arg(long)]
124 limit: Option<i64>,
125 #[arg(long)]
127 signal_type: Option<String>,
128 #[arg(long)]
130 user_id: Option<uuid::Uuid>,
131 },
132}
133
134pub fn requires_api_auth(command: &AdminCommands) -> bool {
135 !matches!(
136 command,
137 AdminCommands::CreateUser { .. } | AdminCommands::CreateKey { .. }
138 )
139}
140
141pub fn ensure_admin_surface_enabled_or_exit() {
142 if !admin_surface_enabled() {
143 exit_error(
144 "Admin commands are disabled by default.",
145 Some("Set KURA_ENABLE_ADMIN_SURFACE=1 only in trusted developer/admin sessions."),
146 );
147 }
148}
149
150pub async fn run(api_url: &str, token: Option<&str>, command: AdminCommands) -> i32 {
151 ensure_admin_surface_enabled_or_exit();
152
153 match command {
154 AdminCommands::CreateUser {
155 email,
156 password,
157 display_name,
158 } => create_user(&email, &password, display_name.as_deref()).await,
159 AdminCommands::CreateKey {
160 user_id,
161 label,
162 expires_in_days,
163 } => create_key(&user_id, &label, expires_in_days).await,
164 AdminCommands::DeleteUser { user_id, confirm } => {
165 delete_user(api_url, &user_id, confirm).await
166 }
167 AdminCommands::ListRequests { status } => {
168 let query = vec![("status".to_string(), status)];
169 api_request(
170 api_url,
171 reqwest::Method::GET,
172 "/v1/admin/access-requests",
173 token,
174 None,
175 &query,
176 &[],
177 false,
178 false,
179 )
180 .await
181 }
182 AdminCommands::ApproveRequest { id } => {
183 api_request(
184 api_url,
185 reqwest::Method::POST,
186 &format!("/v1/admin/access-requests/{id}/approve"),
187 token,
188 None,
189 &[],
190 &[],
191 false,
192 false,
193 )
194 .await
195 }
196 AdminCommands::RejectRequest { id } => {
197 api_request(
198 api_url,
199 reqwest::Method::POST,
200 &format!("/v1/admin/access-requests/{id}/reject"),
201 token,
202 None,
203 &[],
204 &[],
205 false,
206 false,
207 )
208 .await
209 }
210 AdminCommands::CreateInvite {
211 email,
212 expires_in_days,
213 } => {
214 let body = json!({
215 "email": email,
216 "expires_in_days": expires_in_days
217 });
218 api_request(
219 api_url,
220 reqwest::Method::POST,
221 "/v1/admin/invites",
222 token,
223 Some(body),
224 &[],
225 &[],
226 false,
227 false,
228 )
229 .await
230 }
231 AdminCommands::ListInvites { status } => {
232 let query: Vec<(String, String)> = status
233 .map(|s| vec![("status".to_string(), s)])
234 .unwrap_or_default();
235 api_request(
236 api_url,
237 reqwest::Method::GET,
238 "/v1/admin/invites",
239 token,
240 None,
241 &query,
242 &[],
243 false,
244 false,
245 )
246 .await
247 }
248 AdminCommands::SupportReidentify {
249 user_id,
250 reason,
251 ticket_id,
252 requested_mode,
253 expires_at,
254 } => {
255 support_reidentify(
256 api_url,
257 &user_id,
258 &reason,
259 &ticket_id,
260 requested_mode.as_deref(),
261 expires_at.as_deref(),
262 )
263 .await
264 }
265 AdminCommands::Telemetry { command } => telemetry(api_url, token, command).await,
266 }
267}
268
269async fn telemetry(api_url: &str, token: Option<&str>, command: AdminTelemetryCommands) -> i32 {
270 match command {
271 AdminTelemetryCommands::Overview { window_hours } => {
272 let mut query = Vec::new();
273 if let Some(hours) = window_hours {
274 query.push(("window_hours".to_string(), hours.to_string()));
275 }
276 api_request(
277 api_url,
278 reqwest::Method::GET,
279 "/v1/admin/agent/telemetry/overview",
280 token,
281 None,
282 &query,
283 &[],
284 false,
285 false,
286 )
287 .await
288 }
289 AdminTelemetryCommands::Anomalies {
290 window_hours,
291 limit,
292 } => {
293 let mut query = Vec::new();
294 if let Some(hours) = window_hours {
295 query.push(("window_hours".to_string(), hours.to_string()));
296 }
297 if let Some(limit) = limit {
298 query.push(("limit".to_string(), limit.to_string()));
299 }
300 api_request(
301 api_url,
302 reqwest::Method::GET,
303 "/v1/admin/agent/telemetry/anomalies",
304 token,
305 None,
306 &query,
307 &[],
308 false,
309 false,
310 )
311 .await
312 }
313 AdminTelemetryCommands::Signals {
314 window_hours,
315 limit,
316 signal_type,
317 user_id,
318 } => {
319 let mut query = Vec::new();
320 if let Some(hours) = window_hours {
321 query.push(("window_hours".to_string(), hours.to_string()));
322 }
323 if let Some(limit) = limit {
324 query.push(("limit".to_string(), limit.to_string()));
325 }
326 if let Some(signal_type) = signal_type {
327 query.push(("signal_type".to_string(), signal_type));
328 }
329 if let Some(user_id) = user_id {
330 query.push(("user_id".to_string(), user_id.to_string()));
331 }
332 api_request(
333 api_url,
334 reqwest::Method::GET,
335 "/v1/admin/agent/telemetry/signals",
336 token,
337 None,
338 &query,
339 &[],
340 false,
341 false,
342 )
343 .await
344 }
345 }
346}
347
348async fn create_user(email: &str, password: &str, display_name: Option<&str>) -> i32 {
349 if !env_flag_enabled("KURA_ENABLE_BOOTSTRAP_ADMIN") {
350 exit_error(
351 "Direct database bootstrap admin commands are disabled by default.",
352 Some("Set KURA_ENABLE_BOOTSTRAP_ADMIN=1 for one-off developer bootstrap tasks."),
353 );
354 }
355
356 let email_norm = email.trim().to_lowercase();
357 if dry_run_enabled() {
358 let preview = json!({
359 "dry_run": true,
360 "status": "not_executed",
361 "operation": "admin.create_user",
362 "email": email_norm,
363 "display_name": display_name,
364 "password": "<redacted>",
365 "database_write": true
366 });
367 print_json_stdout(&preview);
368 return 0;
369 }
370
371 let database_url = match std::env::var("DATABASE_URL") {
372 Ok(url) => url,
373 Err(_) => exit_error(
374 "DATABASE_URL must be set for admin commands",
375 Some("Admin create commands connect directly to the database for bootstrapping"),
376 ),
377 };
378
379 let pool = match sqlx::postgres::PgPoolOptions::new()
380 .max_connections(1)
381 .connect(&database_url)
382 .await
383 {
384 Ok(p) => p,
385 Err(e) => exit_error(&format!("Failed to connect to database: {e}"), None),
386 };
387
388 let password_hash = match kura_core::auth::hash_password(password) {
389 Ok(h) => h,
390 Err(e) => exit_error(&format!("Failed to hash password: {e}"), None),
391 };
392
393 let user_id = uuid::Uuid::now_v7();
394
395 let mut tx = match pool.begin().await {
396 Ok(tx) => tx,
397 Err(e) => exit_error(&format!("Failed to start transaction: {e}"), None),
398 };
399
400 if let Err(e) = sqlx::query(
401 "INSERT INTO users (id, email, password_hash, display_name) VALUES ($1, $2, $3, $4)",
402 )
403 .bind(user_id)
404 .bind(&email_norm)
405 .bind(&password_hash)
406 .bind(display_name)
407 .execute(&mut *tx)
408 .await
409 {
410 exit_error(&format!("Failed to create user: {e}"), None);
411 }
412
413 if let Err(e) = sqlx::query(
414 "INSERT INTO user_identities \
415 (user_id, provider, provider_subject, email_norm, email_verified_at) \
416 VALUES ($1, 'email_password', $2, $2, NOW())",
417 )
418 .bind(user_id)
419 .bind(&email_norm)
420 .execute(&mut *tx)
421 .await
422 {
423 exit_error(&format!("Failed to create user identity: {e}"), None);
424 }
425
426 if let Err(e) = sqlx::query(
427 "INSERT INTO analysis_subjects (user_id, analysis_subject_id) \
428 VALUES ($1, 'asub_' || replace(gen_random_uuid()::text, '-', '')) \
429 ON CONFLICT (user_id) DO NOTHING",
430 )
431 .bind(user_id)
432 .execute(&mut *tx)
433 .await
434 {
435 exit_error(&format!("Failed to create analysis subject: {e}"), None);
436 }
437
438 if let Err(e) = tx.commit().await {
439 exit_error(&format!("Failed to commit user creation: {e}"), None);
440 }
441
442 let output = json!({
443 "user_id": user_id,
444 "email": email_norm,
445 "display_name": display_name
446 });
447 print_json_stdout(&output);
448 0
449}
450
451async fn create_key(user_id_str: &str, label: &str, expires_in_days: Option<i64>) -> i32 {
452 if !env_flag_enabled("KURA_ENABLE_BOOTSTRAP_ADMIN") {
453 exit_error(
454 "Direct database bootstrap admin commands are disabled by default.",
455 Some("Set KURA_ENABLE_BOOTSTRAP_ADMIN=1 for one-off developer bootstrap tasks."),
456 );
457 }
458
459 let user_id = match uuid::Uuid::parse_str(user_id_str) {
460 Ok(u) => u,
461 Err(e) => exit_error(&format!("Invalid user UUID: {e}"), None),
462 };
463
464 let expires_at = expires_in_days.map(|d| chrono::Utc::now() + chrono::Duration::days(d));
465 if dry_run_enabled() {
466 let preview = json!({
467 "dry_run": true,
468 "status": "not_executed",
469 "operation": "admin.create_key",
470 "user_id": user_id,
471 "label": label,
472 "expires_at": expires_at,
473 "database_write": true
474 });
475 print_json_stdout(&preview);
476 return 0;
477 }
478
479 let database_url = match std::env::var("DATABASE_URL") {
480 Ok(url) => url,
481 Err(_) => exit_error(
482 "DATABASE_URL must be set for admin commands",
483 Some("Admin create commands connect directly to the database for bootstrapping"),
484 ),
485 };
486
487 let pool = match sqlx::postgres::PgPoolOptions::new()
488 .max_connections(1)
489 .connect(&database_url)
490 .await
491 {
492 Ok(p) => p,
493 Err(e) => exit_error(&format!("Failed to connect to database: {e}"), None),
494 };
495
496 let (full_key, key_hash) = kura_core::auth::generate_api_key();
497 let prefix = kura_core::auth::key_prefix(&full_key);
498 let key_id = uuid::Uuid::now_v7();
499 let scopes = vec![
500 "agent:read".to_string(),
501 "agent:write".to_string(),
502 "agent:resolve".to_string(),
503 ];
504
505 if let Err(e) = sqlx::query(
506 "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label, scopes, expires_at) \
507 VALUES ($1, $2, $3, $4, $5, $6, $7)",
508 )
509 .bind(key_id)
510 .bind(user_id)
511 .bind(&key_hash)
512 .bind(&prefix)
513 .bind(label)
514 .bind(scopes.clone())
515 .bind(expires_at)
516 .execute(&pool)
517 .await
518 {
519 exit_error(&format!("Failed to create API key: {e}"), None);
520 }
521
522 let output = json!({
523 "key_id": key_id,
524 "api_key": full_key,
525 "key_prefix": prefix,
526 "label": label,
527 "scopes": scopes,
528 "expires_at": expires_at,
529 "warning": "Store this key securely. It will NOT be shown again."
530 });
531 print_json_stdout(&output);
532 0
533}
534
535async fn delete_user(api_url: &str, user_id: &str, confirm: bool) -> i32 {
536 if !confirm {
537 exit_error(
538 "Account deletion is permanent and irreversible",
539 Some("Add --confirm to proceed: kura admin delete-user --user-id <UUID> --confirm"),
540 );
541 }
542
543 let token = match crate::util::resolve_token(api_url).await {
544 Ok(t) => t,
545 Err(e) => exit_error(
546 &e.to_string(),
547 Some("Run `kura login` or set KURA_API_KEY (admin credentials required)"),
548 ),
549 };
550
551 api_request(
552 api_url,
553 reqwest::Method::DELETE,
554 &format!("/v1/admin/users/{user_id}"),
555 Some(&token),
556 None,
557 &[],
558 &[],
559 false,
560 false,
561 )
562 .await
563}
564
565async fn support_reidentify(
566 api_url: &str,
567 user_id: &str,
568 reason: &str,
569 ticket_id: &str,
570 requested_mode: Option<&str>,
571 expires_at: Option<&str>,
572) -> i32 {
573 let token = match crate::util::resolve_token(api_url).await {
574 Ok(t) => t,
575 Err(e) => exit_error(
576 &e.to_string(),
577 Some("Run `kura login` or set KURA_API_KEY (admin credentials required)"),
578 ),
579 };
580
581 let user_id = match uuid::Uuid::parse_str(user_id) {
582 Ok(v) => v,
583 Err(e) => exit_error(&format!("Invalid user UUID: {e}"), None),
584 };
585
586 let mut body = json!({
587 "user_id": user_id,
588 "reason": reason,
589 "ticket_id": ticket_id,
590 });
591 if let Some(mode) = requested_mode {
592 body["requested_mode"] = json!(mode);
593 }
594 if let Some(expires) = expires_at {
595 body["expires_at"] = json!(expires);
596 }
597
598 api_request(
599 api_url,
600 reqwest::Method::POST,
601 "/v1/admin/support/reidentify",
602 Some(&token),
603 Some(body),
604 &[],
605 &[],
606 false,
607 false,
608 )
609 .await
610}