feat(manager-core,picloud): api_keys_api + deactivation cascade
* 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>
This commit is contained in:
251
crates/manager-core/src/api_keys_api.rs
Normal file
251
crates/manager-core/src/api_keys_api.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
//! `/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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user