* auth: generate_api_key() mints pic_<base32(32 bytes)>, splits the
indexed 8-char prefix, and Argon2-hashes the body. Adds the
data-encoding workspace dep for unpadded base32.
* api_keys_api: POST /api/v1/admin/api-keys (mint, returns raw_token
exactly once), GET (caller's own, no raw), DELETE {id} (caller's
own; 404 deliberately covers both 'missing' and 'not yours').
Mint validation rejects bound keys carrying instance:* scopes (422).
* AdminsState gains the api keys repo; PATCH set_active(false) now
expires every active key for that user alongside session wipe —
Phase 3.5 deactivation symmetry.
* picloud lib wires PostgresApiKeyRepository through AuthDeps into
AdminsState + ApiKeysState; api_keys_router merges into the
guarded_admin layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
252 lines
8.1 KiB
Rust
252 lines
8.1 KiB
Rust
//! `/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<dyn ApiKeyRepository>,
|
|
}
|
|
|
|
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<Scope>,
|
|
/// When set, the key is bound to this app — every `App*(other)`
|
|
/// capability is denied regardless of role.
|
|
#[serde(default)]
|
|
pub app_id: Option<AppId>,
|
|
/// When set, lookup rejects the key after this instant. Absent =
|
|
/// never expires (until explicit DELETE).
|
|
#[serde(default)]
|
|
pub expires_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
/// 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_<base32>`). 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<Scope>,
|
|
pub app_id: Option<AppId>,
|
|
pub expires_at: Option<DateTime<Utc>>,
|
|
pub last_used_at: Option<DateTime<Utc>>,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl From<ApiKeyRow> 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<ApiKeysState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Json(input): Json<MintApiKeyRequest>,
|
|
) -> Result<(StatusCode, Json<MintApiKeyResponse>), 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<ApiKeysState>,
|
|
Extension(principal): Extension<Principal>,
|
|
) -> Result<Json<Vec<ApiKeyDto>>, 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<ApiKeysState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id): Path<ApiKeyId>,
|
|
) -> Result<StatusCode, ApiKeysError> {
|
|
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<AppId>) -> 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()
|
|
}
|
|
}
|