Files
PiCloud/crates/manager-core/src/api_key_repo.rs
MechaCat02 2aab92af31 style: cargo fmt across Phase 3.5 changes
Pure formatting pass — no behavior changes. Catches the line-wrapping
drift across the new authz / api_keys / middleware / handler edits
that piled up during the implementation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:21:37 +02:00

293 lines
9.6 KiB
Rust

//! CRUD over the `api_keys` table — backs the `Authorization: Bearer
//! pic_…` credential flow from blueprint §11.6.
//!
//! The repo never sees the raw token; only the 8-char `prefix` and the
//! Argon2id `hash`. Mint logic (random-bytes generation, prefix split,
//! hash compute) lives in `api_keys_api.rs`. Verification logic
//! (prefix lookup + Argon2 verify per candidate) lives in
//! `auth_middleware.rs`. Both call this repo for the storage layer.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, ApiKeyId, AppId, Scope};
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
pub enum ApiKeyRepositoryError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("api key not found: {0}")]
NotFound(ApiKeyId),
#[error("invalid scope stored in DB: {0}")]
InvalidScope(String),
}
/// Insert payload — built by `api_keys_api` after generating the raw
/// token and hashing it. `hash` is an Argon2id PHC string covering the
/// body of the token (everything after `pic_`); `prefix` is the first
/// 8 chars of that body, indexed for fast candidate lookup.
#[derive(Debug, Clone)]
pub struct NewApiKey {
pub user_id: AdminUserId,
pub hash: String,
pub prefix: String,
pub name: String,
pub scopes: Vec<Scope>,
pub app_id: Option<AppId>,
pub expires_at: Option<DateTime<Utc>>,
}
/// Public-facing row — never exposes the hash. Used for `GET
/// /admin/api-keys` and the `POST` response (alongside the
/// one-shot raw token).
#[derive(Debug, Clone)]
pub struct ApiKeyRow {
pub id: ApiKeyId,
pub user_id: AdminUserId,
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>,
}
/// Verification candidate — includes the Argon2id `hash` and `user_id`
/// so middleware can verify the supplied token and assemble the
/// `Principal`. Kept separate from `ApiKeyRow` so handlers can't leak
/// the hash through a careless `Json(row)`.
#[derive(Debug, Clone)]
pub struct ApiKeyVerification {
pub id: ApiKeyId,
pub user_id: AdminUserId,
pub hash: String,
pub scopes: Vec<Scope>,
pub app_id: Option<AppId>,
}
#[async_trait]
pub trait ApiKeyRepository: Send + Sync {
/// Mint. Caller has already hashed the raw token + computed prefix.
async fn create(&self, key: NewApiKey) -> Result<ApiKeyRow, ApiKeyRepositoryError>;
/// Return every non-expired key with the given 8-char prefix. The
/// caller (middleware) Argon2-verifies the supplied token against
/// each candidate's `hash`. Returning a Vec rather than one row
/// keeps the contract correct even if two keys happen to share a
/// prefix (statistically near-zero but possible).
async fn find_active_by_prefix(
&self,
prefix: &str,
) -> Result<Vec<ApiKeyVerification>, ApiKeyRepositoryError>;
/// Update `last_used_at` for an authenticated request. Inline (not
/// fire-and-forget) so a DB blip surfaces as a 500 rather than
/// silent stale timestamps.
async fn touch_last_used(&self, id: ApiKeyId) -> Result<(), ApiKeyRepositoryError>;
/// Caller's own keys, for `GET /admin/api-keys`.
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<ApiKeyRow>, ApiKeyRepositoryError>;
/// Look up a key by id — used by `DELETE` to verify ownership
/// before issuing the delete.
async fn get(&self, id: ApiKeyId) -> Result<Option<ApiKeyRow>, ApiKeyRepositoryError>;
/// Delete the row only if it belongs to `user_id`. Returns whether
/// a row was actually deleted (false = key didn't exist OR wasn't
/// theirs — handlers map both to 404 to avoid leaking the
/// distinction).
async fn delete_by_id_and_user(
&self,
id: ApiKeyId,
user_id: AdminUserId,
) -> Result<bool, ApiKeyRepositoryError>;
/// Set `expires_at = NOW()` on every active key for a user. Wired
/// into `set_active(false)` so deactivation invalidates both
/// sessions (already done by `AdminSessionRepository::delete_for_user`)
/// and bearer keys at the same moment.
async fn expire_all_for_user(&self, user_id: AdminUserId)
-> Result<u64, ApiKeyRepositoryError>;
}
pub struct PostgresApiKeyRepository {
pool: PgPool,
}
impl PostgresApiKeyRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl ApiKeyRepository for PostgresApiKeyRepository {
async fn create(&self, key: NewApiKey) -> Result<ApiKeyRow, ApiKeyRepositoryError> {
let scope_strings: Vec<String> =
key.scopes.iter().map(|s| s.as_str().to_string()).collect();
let row = sqlx::query_as::<_, ApiKeyRecord>(
"INSERT INTO api_keys \
(user_id, hash, prefix, name, scopes, app_id, expires_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7) \
RETURNING id, user_id, prefix, name, scopes, app_id, \
expires_at, last_used_at, created_at",
)
.bind(key.user_id.into_inner())
.bind(&key.hash)
.bind(&key.prefix)
.bind(&key.name)
.bind(&scope_strings)
.bind(key.app_id.map(picloud_shared::AppId::into_inner))
.bind(key.expires_at)
.fetch_one(&self.pool)
.await?;
row.try_into()
}
async fn find_active_by_prefix(
&self,
prefix: &str,
) -> Result<Vec<ApiKeyVerification>, ApiKeyRepositoryError> {
let rows = sqlx::query_as::<_, ApiKeyVerifyRecord>(
"SELECT id, user_id, hash, scopes, app_id \
FROM api_keys \
WHERE prefix = $1 \
AND (expires_at IS NULL OR expires_at > NOW())",
)
.bind(prefix)
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn touch_last_used(&self, id: ApiKeyId) -> Result<(), ApiKeyRepositoryError> {
sqlx::query("UPDATE api_keys SET last_used_at = NOW() WHERE id = $1")
.bind(id.into_inner())
.execute(&self.pool)
.await?;
Ok(())
}
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<ApiKeyRow>, ApiKeyRepositoryError> {
let rows = sqlx::query_as::<_, ApiKeyRecord>(
"SELECT id, user_id, prefix, name, scopes, app_id, \
expires_at, last_used_at, created_at \
FROM api_keys WHERE user_id = $1 \
ORDER BY created_at DESC",
)
.bind(user_id.into_inner())
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn get(&self, id: ApiKeyId) -> Result<Option<ApiKeyRow>, ApiKeyRepositoryError> {
let row = sqlx::query_as::<_, ApiKeyRecord>(
"SELECT id, user_id, prefix, name, scopes, app_id, \
expires_at, last_used_at, created_at \
FROM api_keys WHERE id = $1",
)
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn delete_by_id_and_user(
&self,
id: ApiKeyId,
user_id: AdminUserId,
) -> Result<bool, ApiKeyRepositoryError> {
let res = sqlx::query("DELETE FROM api_keys WHERE id = $1 AND user_id = $2")
.bind(id.into_inner())
.bind(user_id.into_inner())
.execute(&self.pool)
.await?;
Ok(res.rows_affected() > 0)
}
async fn expire_all_for_user(
&self,
user_id: AdminUserId,
) -> Result<u64, ApiKeyRepositoryError> {
let res = sqlx::query(
"UPDATE api_keys \
SET expires_at = NOW() \
WHERE user_id = $1 \
AND (expires_at IS NULL OR expires_at > NOW())",
)
.bind(user_id.into_inner())
.execute(&self.pool)
.await?;
Ok(res.rows_affected())
}
}
#[derive(sqlx::FromRow)]
struct ApiKeyRecord {
id: uuid::Uuid,
user_id: uuid::Uuid,
prefix: String,
name: String,
scopes: Vec<String>,
app_id: Option<uuid::Uuid>,
expires_at: Option<DateTime<Utc>>,
last_used_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc>,
}
impl TryFrom<ApiKeyRecord> for ApiKeyRow {
type Error = ApiKeyRepositoryError;
fn try_from(r: ApiKeyRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: r.id.into(),
user_id: r.user_id.into(),
prefix: r.prefix,
name: r.name,
scopes: parse_scopes(r.scopes)?,
app_id: r.app_id.map(Into::into),
expires_at: r.expires_at,
last_used_at: r.last_used_at,
created_at: r.created_at,
})
}
}
#[derive(sqlx::FromRow)]
struct ApiKeyVerifyRecord {
id: uuid::Uuid,
user_id: uuid::Uuid,
hash: String,
scopes: Vec<String>,
app_id: Option<uuid::Uuid>,
}
impl TryFrom<ApiKeyVerifyRecord> for ApiKeyVerification {
type Error = ApiKeyRepositoryError;
fn try_from(r: ApiKeyVerifyRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: r.id.into(),
user_id: r.user_id.into(),
hash: r.hash,
scopes: parse_scopes(r.scopes)?,
app_id: r.app_id.map(Into::into),
})
}
}
fn parse_scopes(raw: Vec<String>) -> Result<Vec<Scope>, ApiKeyRepositoryError> {
raw.into_iter()
.map(|s| Scope::from_wire(&s).ok_or(ApiKeyRepositoryError::InvalidScope(s)))
.collect()
}