//! 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, pub app_id: Option, pub expires_at: Option>, } /// 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, pub app_id: Option, pub expires_at: Option>, pub last_used_at: Option>, pub created_at: DateTime, } /// 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, pub app_id: Option, } #[async_trait] pub trait ApiKeyRepository: Send + Sync { /// Mint. Caller has already hashed the raw token + computed prefix. async fn create(&self, key: NewApiKey) -> Result; /// 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, 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, ApiKeyRepositoryError>; /// Look up a key by id — used by `DELETE` to verify ownership /// before issuing the delete. async fn get(&self, id: ApiKeyId) -> Result, 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; /// 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; } 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 { let scope_strings: Vec = 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, 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, 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, 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 { 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 { 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, app_id: Option, expires_at: Option>, last_used_at: Option>, created_at: DateTime, } impl TryFrom for ApiKeyRow { type Error = ApiKeyRepositoryError; fn try_from(r: ApiKeyRecord) -> Result { 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, app_id: Option, } impl TryFrom for ApiKeyVerification { type Error = ApiKeyRepositoryError; fn try_from(r: ApiKeyVerifyRecord) -> Result { 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) -> Result, ApiKeyRepositoryError> { raw.into_iter() .map(|s| Scope::from_wire(&s).ok_or(ApiKeyRepositoryError::InvalidScope(s))) .collect() }