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