//! Low-level Postgres CRUD over `secrets`. Storage-only: it moves //! opaque ciphertext + nonce blobs in and out. Encryption, JSON //! encoding, authorization, name validation, and the value-size cap all //! live one layer up in `SecretsServiceImpl` / `secrets_api`. use async_trait::async_trait; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; use chrono::{DateTime, Utc}; use picloud_shared::AppId; use sqlx::PgPool; #[derive(Debug, thiserror::Error)] pub enum SecretsRepoError { #[error("database error: {0}")] Db(#[from] sqlx::Error), #[error("invalid pagination cursor")] InvalidCursor, } /// An encrypted secret as it lives on disk: ciphertext (auth tag /// appended) plus the nonce it was sealed with. #[derive(Debug, Clone)] pub struct StoredSecret { pub encrypted_value: Vec, pub nonce: Vec, } /// Admin-surface metadata for one secret. Values are never returned — /// only the name and the last-modified timestamp. #[derive(Debug, Clone)] pub struct SecretMeta { pub name: String, pub updated_at: DateTime, } /// One page of names (SDK `list`). #[derive(Debug, Clone)] pub struct SecretsNamePage { pub names: Vec, pub next_cursor: Option, } /// One page of name + updated_at (admin `GET`). #[derive(Debug, Clone)] pub struct SecretsMetaPage { pub items: Vec, pub next_cursor: Option, } /// Repo surface. Exposed as a trait so the service unit tests can /// substitute an in-memory backing without Postgres. #[async_trait] pub trait SecretsRepo: Send + Sync { async fn get( &self, app_id: AppId, name: &str, ) -> Result, SecretsRepoError>; /// Upsert (overwrite if present). async fn set( &self, app_id: AppId, name: &str, encrypted_value: &[u8], nonce: &[u8], ) -> Result<(), SecretsRepoError>; /// Delete; returns whether a row was present. async fn delete(&self, app_id: AppId, name: &str) -> Result; /// Names only — the SDK `list` surface. async fn list_names( &self, app_id: AppId, cursor: Option<&str>, limit: u32, ) -> Result; /// Name + updated_at — the admin `GET` surface. async fn list_meta( &self, app_id: AppId, cursor: Option<&str>, limit: u32, ) -> Result; } pub struct PostgresSecretsRepo { pool: PgPool, } impl PostgresSecretsRepo { #[must_use] pub fn new(pool: PgPool) -> Self { Self { pool } } } const SECRETS_LIST_MAX_LIMIT: u32 = 1_000; const SECRETS_LIST_DEFAULT_LIMIT: u32 = 100; fn clamp_limit(limit: u32) -> u32 { if limit == 0 { SECRETS_LIST_DEFAULT_LIMIT } else { limit.min(SECRETS_LIST_MAX_LIMIT) } } /// Opaque keyset cursor: base64url of the last `name` returned. pub(crate) fn encode_cursor(last_name: &str) -> String { URL_SAFE_NO_PAD.encode(last_name.as_bytes()) } pub(crate) fn decode_cursor(cursor: &str) -> Result { let bytes = URL_SAFE_NO_PAD .decode(cursor) .map_err(|_| SecretsRepoError::InvalidCursor)?; String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor) } #[async_trait] impl SecretsRepo for PostgresSecretsRepo { async fn get( &self, app_id: AppId, name: &str, ) -> Result, SecretsRepoError> { let row: Option<(Vec, Vec)> = sqlx::query_as( "SELECT encrypted_value, nonce FROM secrets WHERE app_id = $1 AND name = $2", ) .bind(app_id.into_inner()) .bind(name) .fetch_optional(&self.pool) .await?; Ok(row.map(|(encrypted_value, nonce)| StoredSecret { encrypted_value, nonce, })) } async fn set( &self, app_id: AppId, name: &str, encrypted_value: &[u8], nonce: &[u8], ) -> Result<(), SecretsRepoError> { sqlx::query( "INSERT INTO secrets (app_id, name, encrypted_value, nonce) \ VALUES ($1, $2, $3, $4) \ ON CONFLICT (app_id, name) DO UPDATE \ SET encrypted_value = EXCLUDED.encrypted_value, \ nonce = EXCLUDED.nonce, \ updated_at = NOW()", ) .bind(app_id.into_inner()) .bind(name) .bind(encrypted_value) .bind(nonce) .execute(&self.pool) .await?; Ok(()) } async fn delete(&self, app_id: AppId, name: &str) -> Result { let res = sqlx::query("DELETE FROM secrets WHERE app_id = $1 AND name = $2") .bind(app_id.into_inner()) .bind(name) .execute(&self.pool) .await?; Ok(res.rows_affected() > 0) } async fn list_names( &self, app_id: AppId, cursor: Option<&str>, limit: u32, ) -> Result { let limit = clamp_limit(limit); let last_name = match cursor { Some(c) => Some(decode_cursor(c)?), None => None, }; let take = i64::from(limit) + 1; let rows: Vec<(String,)> = sqlx::query_as( "SELECT name FROM secrets \ WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \ ORDER BY name ASC LIMIT $3", ) .bind(app_id.into_inner()) .bind(last_name.as_deref()) .bind(take) .fetch_all(&self.pool) .await?; let mut names: Vec = rows.into_iter().map(|(n,)| n).collect(); let next_cursor = if names.len() > limit as usize { names.truncate(limit as usize); names.last().map(|n| encode_cursor(n)) } else { None }; Ok(SecretsNamePage { names, next_cursor }) } async fn list_meta( &self, app_id: AppId, cursor: Option<&str>, limit: u32, ) -> Result { let limit = clamp_limit(limit); let last_name = match cursor { Some(c) => Some(decode_cursor(c)?), None => None, }; let take = i64::from(limit) + 1; let rows: Vec<(String, DateTime)> = sqlx::query_as( "SELECT name, updated_at FROM secrets \ WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \ ORDER BY name ASC LIMIT $3", ) .bind(app_id.into_inner()) .bind(last_name.as_deref()) .bind(take) .fetch_all(&self.pool) .await?; let mut items: Vec = rows .into_iter() .map(|(name, updated_at)| SecretMeta { name, updated_at }) .collect(); let next_cursor = if items.len() > limit as usize { items.truncate(limit as usize); items.last().map(|m| encode_cursor(&m.name)) } else { None }; Ok(SecretsMetaPage { items, next_cursor }) } }