Encrypted per-app secrets, reachable from scripts as
secrets::{get,set,delete,list}(name) and managed from the dashboard
Secrets tab. Values are AES-256-GCM-sealed with the process master key
(picloud_shared::crypto) before they touch Postgres; the repo only ever
sees ciphertext + nonce. JSON round-trip preserves Rhai types.
- migration 0023_secrets.sql (PRIMARY KEY (app_id, name)).
- SecretsService trait (picloud-shared) + SecretsServiceImpl + repo
(manager-core), wired into the Services bundle and Rhai engine.
- Capability::AppSecretsRead/Write (→ script:read / script:write); no
new Scope variants (seven-scope commitment).
- Admin API GET/POST/DELETE /apps/{id}/secrets (list returns names +
updated_at, never values).
- build_app now takes a MasterKey, sourced from PICLOUD_SECRET_KEY in
main.rs; test callers pass a fixed test key.
- 64 KB value cap (PICLOUD_SECRET_MAX_VALUE_BYTES); no ServiceEvent
emission (secret writes don't fire triggers, by design).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
247 lines
7.1 KiB
Rust
247 lines
7.1 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`.
|
|
|
|
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<u8>,
|
|
pub nonce: Vec<u8>,
|
|
}
|
|
|
|
/// 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<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 {
|
|
async fn get(
|
|
&self,
|
|
app_id: AppId,
|
|
name: &str,
|
|
) -> Result<Option<StoredSecret>, 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<bool, SecretsRepoError>;
|
|
|
|
/// Names only — the SDK `list` surface.
|
|
async fn list_names(
|
|
&self,
|
|
app_id: AppId,
|
|
cursor: Option<&str>,
|
|
limit: u32,
|
|
) -> Result<SecretsNamePage, SecretsRepoError>;
|
|
|
|
/// Name + updated_at — the admin `GET` surface.
|
|
async fn list_meta(
|
|
&self,
|
|
app_id: AppId,
|
|
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)
|
|
}
|
|
|
|
#[async_trait]
|
|
impl SecretsRepo for PostgresSecretsRepo {
|
|
async fn get(
|
|
&self,
|
|
app_id: AppId,
|
|
name: &str,
|
|
) -> Result<Option<StoredSecret>, SecretsRepoError> {
|
|
let row: Option<(Vec<u8>, Vec<u8>)> = 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<bool, SecretsRepoError> {
|
|
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<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 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<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,
|
|
app_id: AppId,
|
|
cursor: Option<&str>,
|
|
limit: u32,
|
|
) -> Result<SecretsMetaPage, 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 rows: Vec<(String, DateTime<Utc>)> = 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<SecretMeta> = 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 })
|
|
}
|
|
}
|