Skip to main content

kura_cli/commands/
admin.rs

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    /// Create a new user (requires DATABASE_URL)
9    CreateUser {
10        /// User email
11        #[arg(long)]
12        email: String,
13        /// User password
14        #[arg(long)]
15        password: String,
16        /// Display name
17        #[arg(long)]
18        display_name: Option<String>,
19    },
20    /// Create an API key for a user (requires DATABASE_URL)
21    CreateKey {
22        /// User UUID
23        #[arg(long)]
24        user_id: String,
25        /// Human-readable label (e.g. "my-ci-server")
26        #[arg(long)]
27        label: String,
28        /// Expiration in days (default: never)
29        #[arg(long)]
30        expires_in_days: Option<i64>,
31    },
32    /// Permanently delete a user and all their data (admin only, via API)
33    DeleteUser {
34        /// User UUID to delete
35        #[arg(long)]
36        user_id: String,
37        /// Confirm deletion (required)
38        #[arg(long)]
39        confirm: bool,
40    },
41    /// List access requests (admin only, via API)
42    ListRequests {
43        /// Filter by status: pending, approved, rejected
44        #[arg(long, default_value = "pending")]
45        status: String,
46    },
47    /// Approve an access request and generate invite token (admin only, via API)
48    ApproveRequest {
49        /// Access request UUID
50        id: String,
51    },
52    /// Reject an access request (admin only, via API)
53    RejectRequest {
54        /// Access request UUID
55        id: String,
56    },
57    /// Create a manual invite token (admin only, via API)
58    CreateInvite {
59        /// Bind to specific email (optional)
60        #[arg(long)]
61        email: Option<String>,
62        /// Expiration in days (default: 7)
63        #[arg(long, default_value = "7")]
64        expires_in_days: i64,
65    },
66    /// List invite tokens (admin only, via API)
67    ListInvites {
68        /// Filter: unused, used, expired
69        #[arg(long)]
70        status: Option<String>,
71    },
72    /// Audited break-glass identity lookup by user UUID (admin only)
73    SupportReidentify {
74        /// Target user UUID
75        #[arg(long)]
76        user_id: String,
77        /// Mandatory operational reason
78        #[arg(long)]
79        reason: String,
80        /// Incident/support ticket reference
81        #[arg(long)]
82        ticket_id: String,
83        /// Requested mode (identity_lookup|incident_debug)
84        #[arg(long)]
85        requested_mode: Option<String>,
86        /// Optional RFC3339 expiry timestamp for this access grant
87        #[arg(long)]
88        expires_at: Option<String>,
89    },
90    /// Agent telemetry endpoints (admin only, via API)
91    Telemetry {
92        #[command(subcommand)]
93        command: AdminTelemetryCommands,
94    },
95}
96
97#[derive(Subcommand)]
98pub enum AdminTelemetryCommands {
99    /// Overview metrics for a rolling window
100    Overview {
101        /// Window size in hours (default: 24, max: 720)
102        #[arg(long)]
103        window_hours: Option<i32>,
104    },
105    /// Derived anomaly feed from telemetry overview
106    Anomalies {
107        /// Window size in hours (default: 24, max: 720)
108        #[arg(long)]
109        window_hours: Option<i32>,
110        /// Max anomalies to return (default: 10, max: 30)
111        #[arg(long)]
112        limit: Option<i64>,
113    },
114    /// Raw learning-signal feed
115    Signals {
116        /// Window size in hours (default: 24, max: 720)
117        #[arg(long)]
118        window_hours: Option<i32>,
119        /// Max events to return (default: 120, max: 500)
120        #[arg(long)]
121        limit: Option<i64>,
122        /// Optional signal_type filter
123        #[arg(long)]
124        signal_type: Option<String>,
125        /// Optional user filter (UUID)
126        #[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}