//! `/api/v1/admin/api-keys/*` — bearer API key CRUD (blueprint §11.6). //! //! All endpoints are guarded by `require_authenticated`. Capability //! checks: none — every authenticated user manages **their own** keys. //! The repo enforces caller ownership on `delete`, and `list` is //! scoped to the caller's user_id. No instance-level authority is //! exposed (no listing other users' keys, no admin-issued keys for //! another user — those flows belong with the invite system). //! //! Mint semantics: //! * raw token is returned **exactly once** in the POST response and //! never logged. Lose it = mint a new key. //! * `app_id` (optional) binds the key to one app; capability checks //! deny every `App*(other_app)`. //! * scopes containing `instance:*` are rejected when `app_id` is //! set — the combination is irreconcilable. use std::sync::Arc; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::{delete, get}; use axum::{Extension, Router}; use chrono::{DateTime, Utc}; use picloud_shared::{ApiKeyId, AppId, Principal, Scope}; use serde::{Deserialize, Serialize}; use serde_json::json; use crate::api_key_repo::{ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, NewApiKey}; use crate::auth::generate_api_key; /// Validation bounds for the user-supplied `name` field — keeps the /// dashboard's list view tidy and rejects accidental whole-token /// pastes. const NAME_MIN: usize = 1; const NAME_MAX: usize = 64; #[derive(Clone)] pub struct ApiKeysState { pub keys: Arc, } pub fn api_keys_router(state: ApiKeysState) -> Router { Router::new() .route("/api-keys", get(list_keys).post(mint_key)) .route("/api-keys/{id}", delete(delete_key)) .with_state(state) } // ---------------------------------------------------------------------------- // DTOs // ---------------------------------------------------------------------------- #[derive(Debug, Deserialize)] pub struct MintApiKeyRequest { pub name: String, pub scopes: Vec, /// When set, the key is bound to this app — every `App*(other)` /// capability is denied regardless of role. #[serde(default)] pub app_id: Option, /// When set, lookup rejects the key after this instant. Absent = /// never expires (until explicit DELETE). #[serde(default)] pub expires_at: Option>, } /// Response body for a freshly-minted key. `raw_token` only appears /// here — `GET /api-keys` returns `ApiKeyDto` without it. #[derive(Debug, Serialize)] pub struct MintApiKeyResponse { #[serde(flatten)] pub key: ApiKeyDto, /// The full wire-format token (`pic_`). Shown exactly once; /// store it client-side immediately. pub raw_token: String, } #[derive(Debug, Serialize)] pub struct ApiKeyDto { pub id: ApiKeyId, pub prefix: String, pub name: String, pub scopes: Vec, pub app_id: Option, pub expires_at: Option>, pub last_used_at: Option>, pub created_at: DateTime, } impl From for ApiKeyDto { fn from(r: ApiKeyRow) -> Self { Self { id: r.id, prefix: r.prefix, name: r.name, scopes: r.scopes, app_id: r.app_id, expires_at: r.expires_at, last_used_at: r.last_used_at, created_at: r.created_at, } } } // ---------------------------------------------------------------------------- // Handlers // ---------------------------------------------------------------------------- async fn mint_key( State(state): State, Extension(principal): Extension, Json(input): Json, ) -> Result<(StatusCode, Json), ApiKeysError> { validate_name(&input.name)?; validate_scopes(&input.scopes, input.app_id)?; let minted = generate_api_key().map_err(|e| ApiKeysError::Hash(e.to_string()))?; let row = state .keys .create(NewApiKey { user_id: principal.user_id, hash: minted.hash, prefix: minted.prefix, name: input.name, scopes: input.scopes, app_id: input.app_id, expires_at: input.expires_at, }) .await?; Ok(( StatusCode::CREATED, Json(MintApiKeyResponse { key: row.into(), raw_token: minted.raw, }), )) } async fn list_keys( State(state): State, Extension(principal): Extension, ) -> Result>, ApiKeysError> { let rows = state.keys.list_for_user(principal.user_id).await?; Ok(Json(rows.into_iter().map(Into::into).collect())) } async fn delete_key( State(state): State, Extension(principal): Extension, Path(id): Path, ) -> Result { let deleted = state .keys .delete_by_id_and_user(id, principal.user_id) .await?; if !deleted { // 404 covers both "doesn't exist" and "exists but not yours" — // we deliberately don't leak the distinction. return Err(ApiKeysError::NotFound(id)); } Ok(StatusCode::NO_CONTENT) } // ---------------------------------------------------------------------------- // Validation // ---------------------------------------------------------------------------- fn validate_name(s: &str) -> Result<(), ApiKeysError> { let trimmed = s.trim(); if trimmed.len() < NAME_MIN || trimmed.len() > NAME_MAX { return Err(ApiKeysError::InvalidName(format!( "name must be {NAME_MIN}-{NAME_MAX} characters after trimming" ))); } Ok(()) } fn validate_scopes(scopes: &[Scope], app_id: Option) -> Result<(), ApiKeysError> { if scopes.is_empty() { return Err(ApiKeysError::InvalidScopes( "scopes must be non-empty".into(), )); } // Bound key + any instance:* scope → irreconcilable. if app_id.is_some() && scopes.iter().any(|s| s.is_instance()) { return Err(ApiKeysError::InvalidScopes( "bound keys (app_id set) cannot carry instance:* scopes".into(), )); } Ok(()) } // ---------------------------------------------------------------------------- // Errors // ---------------------------------------------------------------------------- #[derive(Debug, thiserror::Error)] pub enum ApiKeysError { #[error("api key not found: {0}")] NotFound(ApiKeyId), #[error("{0}")] InvalidName(String), #[error("{0}")] InvalidScopes(String), #[error("failed to hash key: {0}")] Hash(String), #[error("repository error: {0}")] Repo(#[from] ApiKeyRepositoryError), } impl IntoResponse for ApiKeysError { fn into_response(self) -> Response { let (status, message) = match &self { Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), Self::InvalidName(_) | Self::InvalidScopes(_) => { (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()) } Self::Hash(_) => { tracing::error!(error = %self, "api key hash failure"); ( StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string(), ) } Self::Repo(ApiKeyRepositoryError::NotFound(_)) => { (StatusCode::NOT_FOUND, self.to_string()) } Self::Repo(ApiKeyRepositoryError::InvalidScope(_)) => { tracing::error!(error = %self, "api key row carries an unknown scope"); ( StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string(), ) } Self::Repo(ApiKeyRepositoryError::Db(e)) => { tracing::error!(error = %e, "api_keys db error"); ( StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string(), ) } }; (status, Json(json!({ "error": message }))).into_response() } }