Files
PiCloud/crates/manager-core/src/secrets_service.rs
MechaCat02 2d11090d1a feat(v1.1.7-secrets): secrets SDK + table + admin API + dashboard
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>
2026-06-04 21:37:17 +02:00

575 lines
18 KiB
Rust

//! `SecretsServiceImpl` — wires the `SecretsRepo` underneath the
//! `picloud_shared::SecretsService` trait that scripts see via the Rhai
//! bridge.
//!
//! Layers added here (vs the raw repo):
//!
//! 1. Name validation (non-empty, ≤255 bytes) at the SDK boundary.
//! 2. **Script-as-gate authz**: when `cx.principal.is_some()` we run
//! `authz::require(...)`; when it's `None` (public unauthenticated
//! HTTP) we skip the check. Cross-app isolation is unaffected — every
//! query is keyed by `cx.app_id`, never an argument.
//! 3. **JSON ⇄ ciphertext**: `set` serializes the value to JSON bytes,
//! enforces the per-secret size cap, and AES-256-GCM-seals it; `get`
//! decrypts and deserializes back to the same JSON shape (a String
//! round-trips to a String, not a JSON-quoted `"\"…\""`).
//!
//! Deliberately **no `ServiceEvent` emission** — secret writes do not
//! fire triggers (footgun avoidance; see `docs/sdk-shape.md` + the
//! v1.1.7 brief §2).
use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{
crypto, validate_secret_name, MasterKey, SdkCallCx, SecretsError, SecretsListPage,
SecretsService,
};
use crate::authz::{self, AuthzRepo, Capability};
use crate::secrets_repo::{SecretsRepo, SecretsRepoError, StoredSecret};
/// Default per-secret plaintext cap (64 KB). Override with
/// `PICLOUD_SECRET_MAX_VALUE_BYTES`.
pub const DEFAULT_SECRET_MAX_VALUE_BYTES: usize = 64 * 1024;
/// Process config for the secrets service.
#[derive(Debug, Clone, Copy)]
pub struct SecretsConfig {
/// Maximum size of the JSON-encoded plaintext, in bytes.
pub max_value_bytes: usize,
}
impl SecretsConfig {
#[must_use]
pub const fn conservative() -> Self {
Self {
max_value_bytes: DEFAULT_SECRET_MAX_VALUE_BYTES,
}
}
/// Read `PICLOUD_SECRET_MAX_VALUE_BYTES`; invalid values are ignored
/// with a warning (keeps the conservative default).
#[must_use]
pub fn from_env() -> Self {
let mut c = Self::conservative();
if let Ok(v) = std::env::var("PICLOUD_SECRET_MAX_VALUE_BYTES") {
match v.trim().parse::<usize>() {
Ok(n) if n > 0 => c.max_value_bytes = n,
_ => tracing::warn!(
value = %v,
"ignoring invalid PICLOUD_SECRET_MAX_VALUE_BYTES (want a positive integer)"
),
}
}
c
}
}
impl Default for SecretsConfig {
fn default() -> Self {
Self::conservative()
}
}
/// Serialize + size-check + encrypt a value into `(ciphertext, nonce)`.
///
/// # Errors
///
/// [`SecretsError::TooLarge`] when the encoded plaintext exceeds
/// `max_value_bytes`; [`SecretsError::Backend`] on a serialization
/// failure (should not happen for a `serde_json::Value`).
pub fn seal(
master_key: &MasterKey,
value: &serde_json::Value,
max_value_bytes: usize,
) -> Result<(Vec<u8>, [u8; crypto::NONCE_LEN]), SecretsError> {
let plaintext = serde_json::to_vec(value)
.map_err(|e| SecretsError::Backend(format!("encode secret value: {e}")))?;
if plaintext.len() > max_value_bytes {
return Err(SecretsError::TooLarge {
limit: max_value_bytes,
actual: plaintext.len(),
});
}
let enc = crypto::encrypt(&plaintext, master_key.as_bytes());
Ok((enc.ciphertext, enc.nonce))
}
/// Decrypt + deserialize a stored secret back to its JSON value.
///
/// # Errors
///
/// [`SecretsError::Corrupted`] when decryption or JSON decoding fails.
pub fn open(
master_key: &MasterKey,
stored: &StoredSecret,
) -> Result<serde_json::Value, SecretsError> {
let plaintext = crypto::decrypt(
&stored.encrypted_value,
&stored.nonce,
master_key.as_bytes(),
)
.map_err(|_| SecretsError::Corrupted)?;
serde_json::from_slice(&plaintext).map_err(|_| SecretsError::Corrupted)
}
pub struct SecretsServiceImpl {
repo: Arc<dyn SecretsRepo>,
authz: Arc<dyn AuthzRepo>,
master_key: MasterKey,
max_value_bytes: usize,
}
impl SecretsServiceImpl {
#[must_use]
pub fn new(
repo: Arc<dyn SecretsRepo>,
authz: Arc<dyn AuthzRepo>,
master_key: MasterKey,
config: SecretsConfig,
) -> Self {
Self {
repo,
authz,
master_key,
max_value_bytes: config.max_value_bytes,
}
}
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), SecretsError> {
if let Some(ref principal) = cx.principal {
authz::require(
&*self.authz,
principal,
Capability::AppSecretsRead(cx.app_id),
)
.await
.map_err(|_| SecretsError::Forbidden)?;
}
Ok(())
}
async fn check_write(&self, cx: &SdkCallCx) -> Result<(), SecretsError> {
if let Some(ref principal) = cx.principal {
authz::require(
&*self.authz,
principal,
Capability::AppSecretsWrite(cx.app_id),
)
.await
.map_err(|_| SecretsError::Forbidden)?;
}
Ok(())
}
}
impl From<SecretsRepoError> for SecretsError {
fn from(e: SecretsRepoError) -> Self {
Self::Backend(e.to_string())
}
}
#[async_trait]
impl SecretsService for SecretsServiceImpl {
async fn get(
&self,
cx: &SdkCallCx,
name: &str,
) -> Result<Option<serde_json::Value>, SecretsError> {
validate_secret_name(name)?;
self.check_read(cx).await?;
let Some(stored) = self.repo.get(cx.app_id, name).await? else {
return Ok(None);
};
match open(&self.master_key, &stored) {
Ok(value) => Ok(Some(value)),
Err(e) => {
// A decrypt failure is operationally significant — surface
// the affected (app_id, name) so an operator can find the
// bad row, but never log the ciphertext or key material.
tracing::error!(
app_id = %cx.app_id,
secret = %name,
"secret could not be decrypted (corrupted row or master-key mismatch)"
);
Err(e)
}
}
}
async fn set(
&self,
cx: &SdkCallCx,
name: &str,
value: serde_json::Value,
) -> Result<(), SecretsError> {
validate_secret_name(name)?;
self.check_write(cx).await?;
let (ciphertext, nonce) = seal(&self.master_key, &value, self.max_value_bytes)?;
self.repo.set(cx.app_id, name, &ciphertext, &nonce).await?;
Ok(())
}
async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError> {
validate_secret_name(name)?;
self.check_write(cx).await?;
Ok(self.repo.delete(cx.app_id, name).await?)
}
async fn list(
&self,
cx: &SdkCallCx,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsListPage, SecretsError> {
self.check_read(cx).await?;
let page = self.repo.list_names(cx.app_id, cursor, limit).await?;
Ok(SecretsListPage {
names: page.names,
next_cursor: page.next_cursor,
})
}
}
// ----------------------------------------------------------------------------
// Tests — in-memory SecretsRepo so unit tests don't need Postgres.
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::authz::{AuthzError, AuthzRepo};
use crate::secrets_repo::{SecretsMetaPage, SecretsNamePage};
use async_trait::async_trait;
use picloud_shared::{
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
UserId,
};
use std::collections::BTreeMap;
use tokio::sync::Mutex;
#[derive(Default)]
struct InMemorySecretsRepo {
data: Mutex<BTreeMap<(AppId, String), StoredSecret>>,
}
#[async_trait]
impl SecretsRepo for InMemorySecretsRepo {
async fn get(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<StoredSecret>, SecretsRepoError> {
Ok(self
.data
.lock()
.await
.get(&(app_id, name.to_string()))
.cloned())
}
async fn set(
&self,
app_id: AppId,
name: &str,
encrypted_value: &[u8],
nonce: &[u8],
) -> Result<(), SecretsRepoError> {
self.data.lock().await.insert(
(app_id, name.to_string()),
StoredSecret {
encrypted_value: encrypted_value.to_vec(),
nonce: nonce.to_vec(),
},
);
Ok(())
}
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
Ok(self
.data
.lock()
.await
.remove(&(app_id, name.to_string()))
.is_some())
}
async fn list_names(
&self,
app_id: AppId,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsNamePage, SecretsRepoError> {
let data = self.data.lock().await;
let last = cursor.map(std::string::ToString::to_string);
let mut names: Vec<String> = data
.iter()
.filter(|((a, _), _)| *a == app_id)
.map(|((_, n), _)| n.clone())
.filter(|n| last.as_ref().is_none_or(|l| n > l))
.collect();
names.sort();
let take = (limit as usize).max(1);
let next_cursor = if names.len() > take {
names.truncate(take);
names.last().cloned()
} else {
None
};
Ok(SecretsNamePage { names, next_cursor })
}
async fn list_meta(
&self,
_app_id: AppId,
_cursor: Option<&str>,
_limit: u32,
) -> Result<SecretsMetaPage, SecretsRepoError> {
unimplemented!("admin-only; not exercised in service tests")
}
}
#[derive(Default)]
struct DenyingAuthzRepo;
#[async_trait]
impl AuthzRepo for DenyingAuthzRepo {
async fn membership(
&self,
_user_id: UserId,
_app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(None)
}
}
fn key() -> MasterKey {
MasterKey::from_bytes([0x5au8; 32])
}
fn svc() -> SecretsServiceImpl {
SecretsServiceImpl::new(
Arc::new(InMemorySecretsRepo::default()),
Arc::new(DenyingAuthzRepo),
key(),
SecretsConfig::conservative(),
)
}
fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
SdkCallCx {
app_id,
script_id: ScriptId::new(),
principal,
execution_id: ExecutionId::new(),
request_id: RequestId::new(),
trigger_depth: 0,
root_execution_id: ExecutionId::new(),
is_dead_letter_handler: false,
event: None,
}
}
fn anon_cx(app_id: AppId) -> SdkCallCx {
cx_with(app_id, None)
}
fn member_no_role_cx(app_id: AppId) -> SdkCallCx {
cx_with(
app_id,
Some(Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Member,
scopes: None,
app_binding: None,
}),
)
}
fn owner_cx(app_id: AppId) -> SdkCallCx {
cx_with(
app_id,
Some(Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: None,
app_binding: None,
}),
)
}
#[tokio::test]
async fn set_get_delete_round_trip() {
let s = svc();
let cx = anon_cx(AppId::new());
s.set(&cx, "stripe_key", serde_json::json!("sk_live_xxx"))
.await
.unwrap();
assert_eq!(
s.get(&cx, "stripe_key").await.unwrap(),
Some(serde_json::json!("sk_live_xxx"))
);
assert!(s.delete(&cx, "stripe_key").await.unwrap());
assert_eq!(s.get(&cx, "stripe_key").await.unwrap(), None);
// Idempotent delete.
assert!(!s.delete(&cx, "stripe_key").await.unwrap());
}
#[tokio::test]
async fn get_missing_returns_none() {
let s = svc();
let cx = anon_cx(AppId::new());
assert_eq!(s.get(&cx, "nope").await.unwrap(), None);
}
#[tokio::test]
async fn empty_name_rejected() {
let s = svc();
let cx = anon_cx(AppId::new());
let err = s.set(&cx, "", serde_json::json!("x")).await.unwrap_err();
assert!(matches!(err, SecretsError::InvalidName(_)));
let err = s.get(&cx, "").await.unwrap_err();
assert!(matches!(err, SecretsError::InvalidName(_)));
}
#[tokio::test]
async fn name_length_capped() {
let s = svc();
let cx = anon_cx(AppId::new());
let long = "a".repeat(256);
let err = s.set(&cx, &long, serde_json::json!(1)).await.unwrap_err();
assert!(matches!(err, SecretsError::InvalidName(_)));
// Exactly 255 is allowed.
let ok = "b".repeat(255);
s.set(&cx, &ok, serde_json::json!(1)).await.unwrap();
}
#[tokio::test]
async fn value_over_cap_rejected() {
let s = SecretsServiceImpl::new(
Arc::new(InMemorySecretsRepo::default()),
Arc::new(DenyingAuthzRepo),
key(),
SecretsConfig {
max_value_bytes: 16,
},
);
let cx = anon_cx(AppId::new());
let big = serde_json::json!("x".repeat(64));
let err = s.set(&cx, "k", big).await.unwrap_err();
assert!(matches!(err, SecretsError::TooLarge { limit: 16, .. }));
}
#[tokio::test]
async fn cross_app_isolation() {
let s = svc();
let a = AppId::new();
let b = AppId::new();
s.set(&anon_cx(a), "shared", serde_json::json!("from-a"))
.await
.unwrap();
s.set(&anon_cx(b), "shared", serde_json::json!("from-b"))
.await
.unwrap();
assert_eq!(
s.get(&anon_cx(a), "shared").await.unwrap(),
Some(serde_json::json!("from-a"))
);
assert_eq!(
s.get(&anon_cx(b), "shared").await.unwrap(),
Some(serde_json::json!("from-b"))
);
}
#[tokio::test]
async fn anonymous_skips_authz() {
let s = svc();
// DenyingAuthzRepo would deny an authed principal; anon skips it.
s.set(&anon_cx(AppId::new()), "k", serde_json::json!(1))
.await
.unwrap();
}
#[tokio::test]
async fn authed_member_without_role_forbidden() {
let s = svc();
let err = s
.set(&member_no_role_cx(AppId::new()), "k", serde_json::json!(1))
.await
.unwrap_err();
assert!(matches!(err, SecretsError::Forbidden));
}
#[tokio::test]
async fn owner_can_write() {
let s = svc();
s.set(&owner_cx(AppId::new()), "k", serde_json::json!(1))
.await
.unwrap();
}
/// Type round-trip: a String comes back a String, a Map a Map, an
/// Array an Array — the JSON encoding is transparent.
#[tokio::test]
async fn type_round_trip_preserves_shape() {
let s = svc();
let cx = anon_cx(AppId::new());
s.set(&cx, "str", serde_json::json!("sk_live_xxx"))
.await
.unwrap();
assert_eq!(
s.get(&cx, "str").await.unwrap(),
Some(serde_json::json!("sk_live_xxx"))
);
let map = serde_json::json!({ "client_id": "abc", "client_secret": "xyz" });
s.set(&cx, "oauth", map.clone()).await.unwrap();
assert_eq!(s.get(&cx, "oauth").await.unwrap(), Some(map));
let arr = serde_json::json!([1, 2, 3]);
s.set(&cx, "arr", arr.clone()).await.unwrap();
assert_eq!(s.get(&cx, "arr").await.unwrap(), Some(arr));
}
#[tokio::test]
async fn corrupted_ciphertext_surfaces_error() {
let repo = Arc::new(InMemorySecretsRepo::default());
let s = SecretsServiceImpl::new(
repo.clone(),
Arc::new(DenyingAuthzRepo),
key(),
SecretsConfig::conservative(),
);
let app = AppId::new();
s.set(&anon_cx(app), "k", serde_json::json!("v"))
.await
.unwrap();
// Corrupt the stored ciphertext directly.
repo.data
.lock()
.await
.get_mut(&(app, "k".to_string()))
.unwrap()
.encrypted_value[0] ^= 0xff;
let err = s.get(&anon_cx(app), "k").await.unwrap_err();
assert!(matches!(err, SecretsError::Corrupted));
}
#[tokio::test]
async fn list_returns_names_paginated() {
let s = svc();
let cx = anon_cx(AppId::new());
for i in 0..5 {
s.set(&cx, &format!("k{i:02}"), serde_json::json!(i))
.await
.unwrap();
}
let p1 = s.list(&cx, None, 2).await.unwrap();
assert_eq!(p1.names, vec!["k00".to_string(), "k01".to_string()]);
assert!(p1.next_cursor.is_some());
let p2 = s.list(&cx, p1.next_cursor.as_deref(), 10).await.unwrap();
assert_eq!(
p2.names,
vec!["k02".to_string(), "k03".to_string(), "k04".to_string()]
);
assert!(p2.next_cursor.is_none());
}
}