Skip to main content

kura_cli/commands/
admin.rs

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