//! 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`. //! //! Phase 3 made secrets polymorphic-owner + env-scoped (migration //! `0049_group_secrets.sql`): a secret is owned by exactly one app OR one //! ancestor group, and a descendant app resolves the nearest one, //! environment-filtered (mirroring `vars` / `config_resolver`). Writes are //! owner-keyed via [`SecretOwner`]; the SDK read path goes through //! [`SecretsRepo::resolve`], which walks the app→group→root chain. 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, GroupId}; use sqlx::PgPool; use uuid::Uuid; use crate::config_resolver::CHAIN_LEVELS_CTE; #[derive(Debug, thiserror::Error)] pub enum SecretsRepoError { #[error("database error: {0}")] Db(#[from] sqlx::Error), #[error("invalid pagination cursor")] InvalidCursor, } /// Who owns a secret (Phase 3). A secret is owned by exactly one app OR /// one group; the owner is bound into the AES-GCM AAD (see /// `secrets_service::secret_aad`) so a cross-owner ciphertext swap fails /// decryption, and it selects the partial-unique conflict target on write. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SecretOwner { App(AppId), Group(GroupId), } /// An encrypted secret as it lives on disk: ciphertext (auth tag /// appended) plus the nonce it was sealed with. /// /// Audit 2026-06-11 H-D1: `version` discriminates the envelope layout. /// `0` = legacy AES-GCM with no AAD (pre-2026-06-11 writes); `1` = /// AES-GCM with AAD bound to the owner+name (see `secrets_service`). #[derive(Debug, Clone)] pub struct StoredSecret { pub encrypted_value: Vec, pub nonce: Vec, pub version: i16, } /// The winner of an inherited-secret resolution: the stored ciphertext /// plus the owner it actually came from (app-own or an ancestor group), /// which the caller needs to pick the right AAD when decrypting. #[derive(Debug, Clone)] pub struct ResolvedSecret { pub owner: SecretOwner, pub stored: StoredSecret, } /// Admin-surface metadata for one secret. Values are never returned — /// only the name, its environment scope, and the last-modified timestamp. #[derive(Debug, Clone)] pub struct SecretMeta { pub name: String, pub environment_scope: 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 { /// Resolve the effective secret for `app_id` by name: walk the /// app→ancestor-group chain (depth 0 = the app), env-filter to the /// app's environment or `*`, and return the nearest winner (with /// `@E` beating `*` within a level). `None` if no level defines it. /// This is the runtime injection path — isolation is anchored to /// `app_id`, so an app only ever sees its own + its ancestors' secrets. async fn resolve( &self, app_id: AppId, name: &str, ) -> Result, SecretsRepoError>; /// Read one owner's OWN secret at a specific env scope (no inheritance). /// Backs the group-gated human value-read and the apply email path. async fn get( &self, owner: SecretOwner, env_scope: &str, name: &str, ) -> Result, SecretsRepoError>; /// Upsert (overwrite if present) one `(owner, env_scope, name)` row. /// `version` is the AES-GCM envelope discriminator. async fn set( &self, owner: SecretOwner, env_scope: &str, name: &str, encrypted_value: &[u8], nonce: &[u8], version: i16, ) -> Result<(), SecretsRepoError>; /// Delete one `(owner, env_scope, name)` row; returns whether a row /// was present. async fn delete( &self, owner: SecretOwner, env_scope: &str, name: &str, ) -> Result; /// Distinct names of an owner's OWN secrets (NOT inherited) — the SDK /// `list` surface. Names are de-duplicated across env scopes. async fn list_names( &self, owner: SecretOwner, cursor: Option<&str>, limit: u32, ) -> Result; /// Name + scope + updated_at of an owner's OWN secrets — the admin /// `GET` surface. async fn list_meta( &self, owner: SecretOwner, 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) } /// `list_meta` orders by `(name, environment_scope)` — a name can now have /// several env-scoped rows (group secrets) — so its keyset cursor must carry /// BOTH columns, else a name whose scopes straddle a page boundary loses its /// tail. Encoded as base64url of `name \x1f scope` (US is not a valid env or /// secret-name char, so it's an unambiguous delimiter). const CURSOR_SEP: char = '\u{1f}'; fn encode_meta_cursor(name: &str, scope: &str) -> String { URL_SAFE_NO_PAD.encode(format!("{name}{CURSOR_SEP}{scope}").as_bytes()) } fn decode_meta_cursor(cursor: &str) -> Result<(String, String), SecretsRepoError> { let bytes = URL_SAFE_NO_PAD .decode(cursor) .map_err(|_| SecretsRepoError::InvalidCursor)?; let s = String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)?; s.split_once(CURSOR_SEP) .map(|(n, sc)| (n.to_string(), sc.to_string())) .ok_or(SecretsRepoError::InvalidCursor) } /// `(owner_column, owner_uuid)` for binding an owner into a query. fn owner_bind(owner: SecretOwner) -> (&'static str, Uuid) { match owner { SecretOwner::App(a) => ("app_id", a.into_inner()), SecretOwner::Group(g) => ("group_id", g.into_inner()), } } #[async_trait] impl SecretsRepo for PostgresSecretsRepo { async fn resolve( &self, app_id: AppId, name: &str, ) -> Result, SecretsRepoError> { // Reuse the shared chain-walk ($1 = app_id), join secrets by name, // env-filter, and take the nearest level — `@E` beating `*` within a // level via the secondary sort key. One row out, or none. let sql = format!( "{CHAIN_LEVELS_CTE} \ SELECT CASE WHEN s.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \ COALESCE(s.app_id, s.group_id) AS owner_id, \ s.encrypted_value, s.nonce, s.version \ FROM chain c \ JOIN secrets s ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \ WHERE s.name = $2 \ AND (s.environment_scope = '*' OR s.environment_scope = c.app_env) \ ORDER BY c.depth ASC, (s.environment_scope <> '*') DESC \ LIMIT 1" ); let row: Option<(String, Uuid, Vec, Vec, i16)> = sqlx::query_as(&sql) .bind(app_id.into_inner()) .bind(name) .fetch_optional(&self.pool) .await?; Ok( row.map(|(owner_kind, owner_id, encrypted_value, nonce, version)| { let owner = if owner_kind == "app" { SecretOwner::App(AppId::from(owner_id)) } else { SecretOwner::Group(GroupId::from(owner_id)) }; ResolvedSecret { owner, stored: StoredSecret { encrypted_value, nonce, version, }, } }), ) } async fn get( &self, owner: SecretOwner, env_scope: &str, name: &str, ) -> Result, SecretsRepoError> { let (col, id) = owner_bind(owner); let sql = format!( "SELECT encrypted_value, nonce, version FROM secrets \ WHERE {col} = $1 AND environment_scope = $2 AND name = $3" ); let row: Option<(Vec, Vec, i16)> = sqlx::query_as(&sql) .bind(id) .bind(env_scope) .bind(name) .fetch_optional(&self.pool) .await?; Ok(row.map(|(encrypted_value, nonce, version)| StoredSecret { encrypted_value, nonce, version, })) } async fn set( &self, owner: SecretOwner, env_scope: &str, name: &str, encrypted_value: &[u8], nonce: &[u8], version: i16, ) -> Result<(), SecretsRepoError> { // Owner-kind-specific SQL: write only the owner's nullable column and // restate the partial-unique predicate as the ON CONFLICT arbiter. let (col, id) = owner_bind(owner); let predicate = match owner { SecretOwner::App(_) => "app_id IS NOT NULL", SecretOwner::Group(_) => "group_id IS NOT NULL", }; let sql = format!( "INSERT INTO secrets ({col}, environment_scope, name, encrypted_value, nonce, version) \ VALUES ($1, $2, $3, $4, $5, $6) \ ON CONFLICT ({col}, environment_scope, name) WHERE {predicate} DO UPDATE \ SET encrypted_value = EXCLUDED.encrypted_value, \ nonce = EXCLUDED.nonce, \ version = EXCLUDED.version, \ updated_at = NOW()" ); sqlx::query(&sql) .bind(id) .bind(env_scope) .bind(name) .bind(encrypted_value) .bind(nonce) .bind(version) .execute(&self.pool) .await?; Ok(()) } async fn delete( &self, owner: SecretOwner, env_scope: &str, name: &str, ) -> Result { let (col, id) = owner_bind(owner); let sql = format!( "DELETE FROM secrets WHERE {col} = $1 AND environment_scope = $2 AND name = $3" ); let res = sqlx::query(&sql) .bind(id) .bind(env_scope) .bind(name) .execute(&self.pool) .await?; Ok(res.rows_affected() > 0) } async fn list_names( &self, owner: SecretOwner, 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 (col, id) = owner_bind(owner); // DISTINCT collapses a name that exists at several env scopes into // one entry — the SDK `list` is a name catalogue, not per-scope. let sql = format!( "SELECT DISTINCT name FROM secrets \ WHERE {col} = $1 AND ($2::text IS NULL OR name > $2) \ ORDER BY name ASC LIMIT $3" ); let rows: Vec<(String,)> = sqlx::query_as(&sql) .bind(id) .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, owner: SecretOwner, cursor: Option<&str>, limit: u32, ) -> Result { let limit = clamp_limit(limit); // Composite keyset on (name, environment_scope) — see encode_meta_cursor. let last = match cursor { Some(c) => Some(decode_meta_cursor(c)?), None => None, }; let (last_name, last_scope) = match &last { Some((n, sc)) => (Some(n.as_str()), Some(sc.as_str())), None => (None, None), }; let take = i64::from(limit) + 1; let (col, id) = owner_bind(owner); let sql = format!( "SELECT name, environment_scope, updated_at FROM secrets \ WHERE {col} = $1 \ AND ($2::text IS NULL OR (name, environment_scope) > ($2, $3)) \ ORDER BY name ASC, environment_scope ASC LIMIT $4" ); let rows: Vec<(String, String, DateTime)> = sqlx::query_as(&sql) .bind(id) .bind(last_name) .bind(last_scope) .bind(take) .fetch_all(&self.pool) .await?; let mut items: Vec = rows .into_iter() .map(|(name, environment_scope, updated_at)| SecretMeta { name, environment_scope, updated_at, }) .collect(); let next_cursor = if items.len() > limit as usize { items.truncate(limit as usize); items .last() .map(|m| encode_meta_cursor(&m.name, &m.environment_scope)) } else { None }; Ok(SecretsMetaPage { items, next_cursor }) } }