//! `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::() { 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; 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 { 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, authz: Arc, master_key: MasterKey, max_value_bytes: usize, } impl SecretsServiceImpl { #[must_use] pub fn new( repo: Arc, authz: Arc, 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 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, 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 { 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 { 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>, } #[async_trait] impl SecretsRepo for InMemorySecretsRepo { async fn get( &self, app_id: AppId, name: &str, ) -> Result, 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 { 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 { let data = self.data.lock().await; let last = cursor.map(std::string::ToString::to_string); let mut names: Vec = 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 { 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, 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) -> 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()); } }