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>
293 lines
9.6 KiB
Rust
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()
|
|
}
|