`list_meta` orders by `(name, environment_scope)` — a group secret name can carry several env-scoped rows — but the cursor encoded only `name` and paginated with `name > $cursor`. When a name's scopes straddled a page boundary, the remaining scope rows were silently skipped from the admin listing. Switch to a composite `(name, environment_scope)` keyset (base64url of `name \x1f scope`) and a Postgres row-comparison predicate. App secrets (single `*` scope per name) were unaffected; group secrets with multi-scope names now page completely. Found in final branch review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
429 lines
14 KiB
Rust
429 lines
14 KiB
Rust
//! 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<u8>,
|
|
pub nonce: Vec<u8>,
|
|
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<Utc>,
|
|
}
|
|
|
|
/// One page of names (SDK `list`).
|
|
#[derive(Debug, Clone)]
|
|
pub struct SecretsNamePage {
|
|
pub names: Vec<String>,
|
|
pub next_cursor: Option<String>,
|
|
}
|
|
|
|
/// One page of name + updated_at (admin `GET`).
|
|
#[derive(Debug, Clone)]
|
|
pub struct SecretsMetaPage {
|
|
pub items: Vec<SecretMeta>,
|
|
pub next_cursor: Option<String>,
|
|
}
|
|
|
|
/// 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<Option<ResolvedSecret>, 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<Option<StoredSecret>, 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<bool, SecretsRepoError>;
|
|
|
|
/// 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<SecretsNamePage, SecretsRepoError>;
|
|
|
|
/// 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<SecretsMetaPage, SecretsRepoError>;
|
|
}
|
|
|
|
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<String, SecretsRepoError> {
|
|
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<Option<ResolvedSecret>, 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<u8>, Vec<u8>, 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<Option<StoredSecret>, 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<u8>, Vec<u8>, 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<bool, SecretsRepoError> {
|
|
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<SecretsNamePage, SecretsRepoError> {
|
|
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<String> = 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<SecretsMetaPage, SecretsRepoError> {
|
|
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<Utc>)> = sqlx::query_as(&sql)
|
|
.bind(id)
|
|
.bind(last_name)
|
|
.bind(last_scope)
|
|
.bind(take)
|
|
.fetch_all(&self.pool)
|
|
.await?;
|
|
|
|
let mut items: Vec<SecretMeta> = 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 })
|
|
}
|
|
}
|