Compare commits
2 Commits
feat/v1.1.
...
2d11090d1a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d11090d1a | ||
|
|
dc2e4fa01f |
105
Cargo.lock
generated
105
Cargo.lock
generated
@@ -2,6 +2,41 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -400,6 +435,16 @@ dependencies = [
|
||||
"phf_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.1"
|
||||
@@ -560,9 +605,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -880,6 +935,16 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -1201,6 +1266,15 @@ version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
@@ -1477,6 +1551,12 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -1770,15 +1850,18 @@ dependencies = [
|
||||
name = "picloud-shared"
|
||||
version = "1.1.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chrono",
|
||||
"hmac",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -1821,6 +1904,18 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
@@ -3229,6 +3324,16 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -81,6 +81,13 @@ sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
base64 = "0.22"
|
||||
data-encoding = "2.6"
|
||||
# AES-256-GCM at-rest encryption for per-app secrets + the realtime
|
||||
# signing key (v1.1.7). Audited, pure-Rust RustCrypto AEAD.
|
||||
aes-gcm = { version = "0.10", features = ["aes", "alloc"] }
|
||||
|
||||
# Outbound SMTP email (v1.1.7). Async transport over the Tokio runtime
|
||||
# with rustls TLS; built messages for text + multipart-alternative.
|
||||
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder", "hostname"] }
|
||||
|
||||
# Stdlib utility crates (v1.1.0 stdlib PR — registered into the
|
||||
# Rhai engine as the regex::/random::/etc. namespaces)
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod files;
|
||||
pub mod http;
|
||||
pub mod kv;
|
||||
pub mod pubsub;
|
||||
pub mod secrets;
|
||||
pub mod stdlib;
|
||||
|
||||
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
||||
@@ -41,5 +42,6 @@ pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCal
|
||||
dead_letters::register(engine, services, cx.clone());
|
||||
http::register(engine, services, cx.clone());
|
||||
files::register(engine, services, cx.clone());
|
||||
pubsub::register(engine, services, cx);
|
||||
pubsub::register(engine, services, cx.clone());
|
||||
secrets::register(engine, services, cx);
|
||||
}
|
||||
|
||||
153
crates/executor-core/src/sdk/secrets.rs
Normal file
153
crates/executor-core/src/sdk/secrets.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
//! `secrets::` Rhai bridge — encrypted per-app secrets (v1.1.7).
|
||||
//!
|
||||
//! ```rhai
|
||||
//! secrets::set("stripe_key", "sk_live_xxx");
|
||||
//! secrets::set("oauth", #{ client_id: "abc", client_secret: "xyz" });
|
||||
//! let key = secrets::get("stripe_key"); // value or ()
|
||||
//! let removed = secrets::delete("stripe_key"); // bool
|
||||
//! let page = secrets::list(#{ cursor: (), limit: 100 });
|
||||
//! // page = #{ names: [...], next_cursor: () | "..." }
|
||||
//! ```
|
||||
//!
|
||||
//! Collection-less (secrets are per-app, like pubsub topics) so there's
|
||||
//! no `::collection(...)`. Values are any JSON-serializable Rhai value
|
||||
//! (String/Map/Array/number/bool); a String round-trips back as a
|
||||
//! String. `app_id` is derived from `cx.app_id` in the service — it
|
||||
//! never appears in the script-side signature, preserving cross-app
|
||||
//! isolation.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{SdkCallCx, SecretsError, SecretsListPage, Services};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
|
||||
use super::bridge::{dynamic_to_json, json_to_dynamic};
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let svc = services.secrets.clone();
|
||||
let mut module = Module::new();
|
||||
|
||||
// secrets::set(name, value) — overwrites if present.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"set",
|
||||
move |name: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||
let json = dynamic_to_json(&value);
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.set(&cx, name, json).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// secrets::get(name) — decoded value, or () if missing.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"get",
|
||||
move |name: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let opt = block_on(async move { svc.get(&cx, name).await })?;
|
||||
Ok(opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// secrets::delete(name) — bool was-present.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"delete",
|
||||
move |name: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.delete(&cx, name).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// secrets::list(#{ cursor, limit }) — names only, cursor-paginated.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"list",
|
||||
move |opts: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||
let (cursor, limit) = parse_list_opts(&opts)?;
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let page: SecretsListPage =
|
||||
block_on(async move { svc.list(&cx, cursor.as_deref(), limit).await })?;
|
||||
Ok(list_page_to_map(page))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
engine.register_static_module("secrets", module.into());
|
||||
}
|
||||
|
||||
/// Pull `cursor` (string or `()`) and `limit` (int or `()`) out of the
|
||||
/// options map. Unknown/extra keys are ignored.
|
||||
fn parse_list_opts(opts: &Map) -> Result<(Option<String>, u32), Box<EvalAltResult>> {
|
||||
let cursor = match opts.get("cursor") {
|
||||
None => None,
|
||||
Some(d) if d.is_unit() => None,
|
||||
Some(d) if d.is_string() => Some(d.clone().into_string().unwrap_or_default()),
|
||||
Some(_) => return Err(runtime_err("secrets::list: cursor must be a string or ()")),
|
||||
};
|
||||
let limit = match opts.get("limit") {
|
||||
None => 0,
|
||||
Some(d) if d.is_unit() => 0,
|
||||
Some(d) => {
|
||||
let n = d
|
||||
.as_int()
|
||||
.map_err(|_| runtime_err("secrets::list: limit must be an integer or ()"))?;
|
||||
u32::try_from(n.max(0)).unwrap_or(u32::MAX)
|
||||
}
|
||||
};
|
||||
Ok((cursor, limit))
|
||||
}
|
||||
|
||||
fn list_page_to_map(page: SecretsListPage) -> Map {
|
||||
let mut m = Map::new();
|
||||
let names: Array = page.names.into_iter().map(Dynamic::from).collect();
|
||||
m.insert("names".into(), names.into());
|
||||
m.insert(
|
||||
"next_cursor".into(),
|
||||
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
m
|
||||
}
|
||||
|
||||
// Returns the boxed error directly because every caller needs a
|
||||
// `Box<EvalAltResult>` (Rhai's error type), matching the other bridges.
|
||||
#[allow(clippy::unnecessary_box_returns)]
|
||||
fn runtime_err(msg: &str) -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(msg.into(), rhai::Position::NONE).into()
|
||||
}
|
||||
|
||||
/// Run a `SecretsService` future inside the synchronous Rhai context,
|
||||
/// mapping any `SecretsError` to a Rhai runtime error. Mirrors
|
||||
/// `kv::block_on` / `pubsub::block_on`.
|
||||
fn block_on<T, F>(fut: F) -> Result<T, Box<EvalAltResult>>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, SecretsError>> + Send,
|
||||
T: Send,
|
||||
{
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("secrets: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("secrets: {err}").into(), rhai::Position::NONE).into()
|
||||
})
|
||||
}
|
||||
@@ -101,6 +101,7 @@ async fn original_backend_error_is_logged_at_error_level() {
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(picloud_shared::NoopFilesService),
|
||||
Arc::new(picloud_shared::NoopPubsubService),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
);
|
||||
let engine = Engine::new(Limits::default(), services);
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(picloud_shared::NoopFilesService),
|
||||
Arc::new(picloud_shared::NoopPubsubService),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(picloud_shared::NoopFilesService),
|
||||
Arc::new(picloud_shared::NoopPubsubService),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(InMemoryFiles::default()),
|
||||
Arc::new(picloud_shared::NoopPubsubService),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ fn engine_with(http: Arc<dyn HttpService>) -> Arc<Engine> {
|
||||
http,
|
||||
Arc::new(picloud_shared::NoopFilesService),
|
||||
Arc::new(picloud_shared::NoopPubsubService),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(picloud_shared::NoopFilesService),
|
||||
Arc::new(picloud_shared::NoopPubsubService),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ fn make_engine(svc: Arc<RecordingPubsub>) -> Arc<Engine> {
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(NoopFilesService),
|
||||
svc,
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
212
crates/executor-core/tests/sdk_secrets.rs
Normal file
212
crates/executor-core/tests/sdk_secrets.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
//! `secrets::` SDK bridge integration tests — runs a real Rhai engine
|
||||
//! against an in-memory `SecretsService` impl. Mirrors `sdk_kv.rs`: the
|
||||
//! engine runs under `spawn_blocking` so the bridge's `block_on` has a
|
||||
//! reachable runtime.
|
||||
//!
|
||||
//! This exercises the Rhai⇄JSON plumbing + the static `secrets` module
|
||||
//! (set/get/delete/list, the missing→() contract, and the
|
||||
//! String/Map/Array type round-trip). Encryption + authz + the
|
||||
//! cross-app boundary are unit-tested at the service layer in
|
||||
//! `manager-core::secrets_service`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService,
|
||||
NoopKvService, NoopModuleSource, RequestId, ScriptId, ScriptSandbox, SdkCallCx, SecretsError,
|
||||
SecretsListPage, SecretsService, Services,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// In-memory secrets store keyed by `(app_id, name)`. Stores the JSON
|
||||
/// value directly — the bridge test only cares about the Rhai plumbing,
|
||||
/// not the at-rest encryption (which the service layer owns).
|
||||
#[derive(Default)]
|
||||
struct InMemorySecrets {
|
||||
data: Mutex<BTreeMap<(AppId, String), Value>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SecretsService for InMemorySecrets {
|
||||
async fn get(&self, cx: &SdkCallCx, name: &str) -> Result<Option<Value>, SecretsError> {
|
||||
picloud_shared::validate_secret_name(name)?;
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(cx.app_id, name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn set(&self, cx: &SdkCallCx, name: &str, value: Value) -> Result<(), SecretsError> {
|
||||
picloud_shared::validate_secret_name(name)?;
|
||||
self.data
|
||||
.lock()
|
||||
.await
|
||||
.insert((cx.app_id, name.to_string()), value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError> {
|
||||
picloud_shared::validate_secret_name(name)?;
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(cx.app_id, name.to_string()))
|
||||
.is_some())
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsListPage, SecretsError> {
|
||||
let data = self.data.lock().await;
|
||||
let mut names: Vec<String> = data
|
||||
.iter()
|
||||
.filter(|((a, _), _)| *a == cx.app_id)
|
||||
.map(|((_, n), _)| n.clone())
|
||||
.filter(|n| cursor.is_none_or(|c| n.as_str() > c))
|
||||
.collect();
|
||||
names.sort();
|
||||
let take = if limit == 0 {
|
||||
usize::MAX
|
||||
} else {
|
||||
limit as usize
|
||||
};
|
||||
let next_cursor = if names.len() > take {
|
||||
names.truncate(take);
|
||||
names.last().cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(SecretsListPage { names, next_cursor })
|
||||
}
|
||||
}
|
||||
|
||||
fn make_engine() -> Arc<Engine> {
|
||||
let services = Services::new(
|
||||
Arc::new(NoopKvService),
|
||||
Arc::new(NoopDocsService),
|
||||
Arc::new(NoopDeadLetterService),
|
||||
Arc::new(NoopEventEmitter),
|
||||
Arc::new(NoopModuleSource),
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(picloud_shared::NoopFilesService),
|
||||
Arc::new(picloud_shared::NoopPubsubService),
|
||||
Arc::new(InMemorySecrets::default()),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
let execution_id = ExecutionId::new();
|
||||
ExecRequest {
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
script_name: "secrets-test".into(),
|
||||
invocation_type: InvocationType::Http,
|
||||
path: "/secrets-test".into(),
|
||||
headers: BTreeMap::new(),
|
||||
body: Value::Null,
|
||||
params: BTreeMap::new(),
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_script(engine: Arc<Engine>, src: &str, req: ExecRequest) -> Value {
|
||||
let src = src.to_string();
|
||||
tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||
.await
|
||||
.expect("spawn_blocking should not panic")
|
||||
.expect("script execution should succeed")
|
||||
.body
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn set_then_get_string_round_trips() {
|
||||
let engine = make_engine();
|
||||
let src = r#"
|
||||
secrets::set("stripe_key", "sk_live_xxx");
|
||||
secrets::get("stripe_key")
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(AppId::new())).await;
|
||||
// A String comes back a String, not a JSON-quoted "\"sk_live_xxx\"".
|
||||
assert_eq!(body, json!("sk_live_xxx"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn set_then_get_map_round_trips() {
|
||||
let engine = make_engine();
|
||||
let src = r#"
|
||||
secrets::set("oauth", #{ client_id: "abc", client_secret: "xyz" });
|
||||
secrets::get("oauth")
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(AppId::new())).await;
|
||||
assert_eq!(body, json!({ "client_id": "abc", "client_secret": "xyz" }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_missing_returns_unit() {
|
||||
let engine = make_engine();
|
||||
let src = r#"
|
||||
let v = secrets::get("nope");
|
||||
#{ is_unit: type_of(v) == "()" }
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(AppId::new())).await;
|
||||
assert_eq!(body, json!({ "is_unit": true }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn delete_returns_was_present() {
|
||||
let engine = make_engine();
|
||||
let src = r#"
|
||||
secrets::set("k", "v");
|
||||
let first = secrets::delete("k");
|
||||
let second = secrets::delete("k");
|
||||
#{ first: first, second: second }
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(AppId::new())).await;
|
||||
assert_eq!(body, json!({ "first": true, "second": false }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn list_returns_names_and_cursor() {
|
||||
let engine = make_engine();
|
||||
let src = r#"
|
||||
secrets::set("a", 1);
|
||||
secrets::set("b", 2);
|
||||
secrets::set("c", 3);
|
||||
let page = secrets::list(#{ cursor: (), limit: 2 });
|
||||
page
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(AppId::new())).await;
|
||||
assert_eq!(body["names"], json!(["a", "b"]));
|
||||
assert_eq!(body["next_cursor"], json!("b"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn empty_name_throws() {
|
||||
let engine = make_engine();
|
||||
let src = r#" secrets::set("", "v"); #{ ok: true } "#;
|
||||
let app = AppId::new();
|
||||
let out = tokio::task::spawn_blocking(move || engine.execute(src, baseline_request(app)))
|
||||
.await
|
||||
.expect("spawn_blocking");
|
||||
assert!(out.is_err(), "empty secret name must throw");
|
||||
}
|
||||
@@ -94,6 +94,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(NoopFilesService),
|
||||
Arc::new(FakeMintPubsub),
|
||||
Arc::new(picloud_shared::NoopSecretsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
24
crates/manager-core/migrations/0023_secrets.sql
Normal file
24
crates/manager-core/migrations/0023_secrets.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- v1.1.7: encrypted per-app secrets.
|
||||
--
|
||||
-- Operational config (API keys, OAuth tokens, webhook signing keys)
|
||||
-- encrypted at rest with the process master key (AES-256-GCM). Both the
|
||||
-- ciphertext (16-byte GCM auth tag appended) and the 12-byte nonce are
|
||||
-- stored; the master key itself never lives in the database. See
|
||||
-- `picloud_shared::crypto` + `manager-core::secrets_service`.
|
||||
--
|
||||
-- This is the user-facing `secrets::*` store. It is intentionally
|
||||
-- separate from `app_secrets` (the one-row-per-app realtime signing
|
||||
-- key, 0022): different cardinality (many named rows per app), and the
|
||||
-- realtime key is encrypted in place by migration 0025.
|
||||
|
||||
CREATE TABLE secrets (
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
encrypted_value BYTEA NOT NULL, -- ciphertext incl. 16-byte GCM auth tag
|
||||
nonce BYTEA NOT NULL, -- 12 bytes
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (app_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_secrets_app ON secrets (app_id);
|
||||
@@ -89,6 +89,14 @@ pub enum Capability {
|
||||
/// (v1.1.5). Maps to `script:write` on API keys (a publish is a
|
||||
/// write that fans out to subscribers). Granted to `editor`+.
|
||||
AppPubsubPublish(AppId),
|
||||
/// Read a decrypted secret from this app's secrets store (v1.1.7).
|
||||
/// Same trust shape as KV/docs/files read — granted to `viewer`+,
|
||||
/// maps to `script:read` on API keys. Honors the seven-scope
|
||||
/// commitment.
|
||||
AppSecretsRead(AppId),
|
||||
/// Write (set/delete) a secret in this app's secrets store (v1.1.7).
|
||||
/// Granted to `editor`+, maps to `script:write` on API keys.
|
||||
AppSecretsWrite(AppId),
|
||||
/// Create / list / delete triggers for this app (v1.1.1). Maps to
|
||||
/// `app:admin` on API keys — triggers are app-configuration acts
|
||||
/// rather than data-plane access. Granted to `app_admin`+.
|
||||
@@ -128,6 +136,8 @@ impl Capability {
|
||||
| Self::AppFilesRead(id)
|
||||
| Self::AppFilesWrite(id)
|
||||
| Self::AppPubsubPublish(id)
|
||||
| Self::AppSecretsRead(id)
|
||||
| Self::AppSecretsWrite(id)
|
||||
| Self::AppManageTriggers(id)
|
||||
| Self::AppDeadLetterManage(id)
|
||||
| Self::AppTopicManage(id) => Some(id),
|
||||
@@ -148,13 +158,15 @@ impl Capability {
|
||||
Self::AppRead(_)
|
||||
| Self::AppKvRead(_)
|
||||
| Self::AppDocsRead(_)
|
||||
| Self::AppFilesRead(_) => Scope::ScriptRead,
|
||||
| Self::AppFilesRead(_)
|
||||
| Self::AppSecretsRead(_) => Scope::ScriptRead,
|
||||
Self::AppWriteScript(_)
|
||||
| Self::AppKvWrite(_)
|
||||
| Self::AppDocsWrite(_)
|
||||
| Self::AppHttpRequest(_)
|
||||
| Self::AppFilesWrite(_)
|
||||
| Self::AppPubsubPublish(_) => Scope::ScriptWrite,
|
||||
| Self::AppPubsubPublish(_)
|
||||
| Self::AppSecretsWrite(_) => Scope::ScriptWrite,
|
||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||
Self::AppAdmin(_)
|
||||
@@ -305,6 +317,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
| Capability::AppKvRead(_)
|
||||
| Capability::AppDocsRead(_)
|
||||
| Capability::AppFilesRead(_)
|
||||
| Capability::AppSecretsRead(_)
|
||||
);
|
||||
let in_editor = in_viewer
|
||||
|| matches!(
|
||||
@@ -316,6 +329,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
| Capability::AppHttpRequest(_)
|
||||
| Capability::AppFilesWrite(_)
|
||||
| Capability::AppPubsubPublish(_)
|
||||
| Capability::AppSecretsWrite(_)
|
||||
);
|
||||
let in_app_admin = in_editor
|
||||
|| matches!(
|
||||
|
||||
@@ -53,6 +53,9 @@ pub mod route_admin;
|
||||
pub mod route_repo;
|
||||
pub mod sandbox;
|
||||
pub mod scheduler;
|
||||
pub mod secrets_api;
|
||||
pub mod secrets_repo;
|
||||
pub mod secrets_service;
|
||||
pub mod ssrf;
|
||||
pub mod topic_repo;
|
||||
pub mod topics_api;
|
||||
@@ -134,6 +137,15 @@ pub use repo::{
|
||||
pub use route_admin::{compile_routes, route_admin_router, RouteAdminState};
|
||||
pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
|
||||
pub use sandbox::{CeilingError, SandboxCeiling};
|
||||
pub use secrets_api::{secrets_router, SecretsApiError, SecretsState};
|
||||
pub use secrets_repo::{
|
||||
PostgresSecretsRepo, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo,
|
||||
SecretsRepoError, StoredSecret,
|
||||
};
|
||||
pub use secrets_service::{
|
||||
open as open_secret, seal as seal_secret, SecretsConfig, SecretsServiceImpl,
|
||||
DEFAULT_SECRET_MAX_VALUE_BYTES,
|
||||
};
|
||||
pub use topic_repo::{PostgresTopicRepo, Topic, TopicAuthMode, TopicRepo, TopicRepoError};
|
||||
pub use topics_api::{topics_router, TopicsApiError, TopicsState};
|
||||
pub use trigger_config::{BackoffShape, TriggerConfig};
|
||||
|
||||
232
crates/manager-core/src/secrets_api.rs
Normal file
232
crates/manager-core/src/secrets_api.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
//! `/api/v1/admin/apps/{id}/secrets*` — secrets admin endpoints
|
||||
//! (v1.1.7).
|
||||
//!
|
||||
//! * `GET /apps/{id}/secrets` — list names + updated_at
|
||||
//! (NEVER values).
|
||||
//! * `POST /apps/{id}/secrets` — set/overwrite a secret.
|
||||
//! * `DELETE /apps/{id}/secrets/{name}` — delete a secret.
|
||||
//!
|
||||
//! Set/delete are gated by `AppSecretsWrite` (→ `script:write`); list by
|
||||
//! `AppSecretsRead` (→ `script:read`). The list surface deliberately
|
||||
//! returns only names + timestamps — the dashboard never receives
|
||||
//! plaintext. Values are encrypted with the process master key before
|
||||
//! they touch the database (same envelope as the script `secrets::set`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{validate_secret_name, AppId, MasterKey, Principal, SecretsError};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::secrets_repo::{SecretsRepo, SecretsRepoError};
|
||||
use crate::secrets_service::seal;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SecretsState {
|
||||
pub repo: Arc<dyn SecretsRepo>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
pub master_key: MasterKey,
|
||||
pub max_value_bytes: usize,
|
||||
}
|
||||
|
||||
pub fn secrets_router(state: SecretsState) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{app_id}/secrets", get(list_secrets).post(set_secret))
|
||||
.route(
|
||||
"/apps/{app_id}/secrets/{name}",
|
||||
axum::routing::delete(delete_secret),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListQuery {
|
||||
#[serde(default)]
|
||||
pub cursor: Option<String>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct SecretItem {
|
||||
name: String,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct ListSecretsResponse {
|
||||
secrets: Vec<SecretItem>,
|
||||
next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_secrets(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(app_id): Path<AppId>,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> Result<Json<ListSecretsResponse>, SecretsApiError> {
|
||||
ensure_app_exists(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppSecretsRead(app_id),
|
||||
)
|
||||
.await?;
|
||||
let page = s
|
||||
.repo
|
||||
.list_meta(app_id, q.cursor.as_deref(), q.limit.unwrap_or(0))
|
||||
.await?;
|
||||
Ok(Json(ListSecretsResponse {
|
||||
secrets: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|m| SecretItem {
|
||||
name: m.name,
|
||||
updated_at: m.updated_at,
|
||||
})
|
||||
.collect(),
|
||||
next_cursor: page.next_cursor,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetSecretRequest {
|
||||
pub name: String,
|
||||
/// Any JSON value — the dashboard sends a single-line string, but
|
||||
/// maps/arrays/numbers round-trip too (matching `secrets::set`).
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
async fn set_secret(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(app_id): Path<AppId>,
|
||||
Json(input): Json<SetSecretRequest>,
|
||||
) -> Result<StatusCode, SecretsApiError> {
|
||||
ensure_app_exists(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppSecretsWrite(app_id),
|
||||
)
|
||||
.await?;
|
||||
validate_secret_name(&input.name)?;
|
||||
let (ciphertext, nonce) = seal(&s.master_key, &input.value, s.max_value_bytes)?;
|
||||
s.repo.set(app_id, &input.name, &ciphertext, &nonce).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn delete_secret(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((app_id, name)): Path<(AppId, String)>,
|
||||
) -> Result<StatusCode, SecretsApiError> {
|
||||
ensure_app_exists(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppSecretsWrite(app_id),
|
||||
)
|
||||
.await?;
|
||||
if !s.repo.delete(app_id, &name).await? {
|
||||
return Err(SecretsApiError::NotFound);
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn ensure_app_exists(apps: &dyn AppRepository, app_id: AppId) -> Result<(), SecretsApiError> {
|
||||
apps.get_by_id(app_id)
|
||||
.await
|
||||
.map_err(|e| SecretsApiError::Backend(e.to_string()))?
|
||||
.ok_or(SecretsApiError::AppNotFound)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SecretsApiError {
|
||||
#[error("app not found")]
|
||||
AppNotFound,
|
||||
#[error("secret not found")]
|
||||
NotFound,
|
||||
#[error("invalid request: {0}")]
|
||||
Invalid(String),
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("secrets backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for SecretsApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for SecretsApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SecretsRepoError> for SecretsApiError {
|
||||
fn from(e: SecretsRepoError) -> Self {
|
||||
match e {
|
||||
SecretsRepoError::InvalidCursor => Self::Invalid("invalid pagination cursor".into()),
|
||||
SecretsRepoError::Db(e) => Self::Backend(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SecretsError> for SecretsApiError {
|
||||
fn from(e: SecretsError) -> Self {
|
||||
match e {
|
||||
SecretsError::InvalidName(m) => Self::Invalid(m),
|
||||
SecretsError::TooLarge { .. } => Self::Invalid(e.to_string()),
|
||||
SecretsError::Forbidden => Self::Forbidden,
|
||||
other => Self::Backend(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for SecretsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound | Self::NotFound => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Invalid(_) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "secrets admin authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "secrets admin backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
246
crates/manager-core/src/secrets_repo.rs
Normal file
246
crates/manager-core/src/secrets_repo.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
//! 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 })
|
||||
}
|
||||
}
|
||||
574
crates/manager-core/src/secrets_service.rs
Normal file
574
crates/manager-core/src/secrets_service.rs
Normal file
@@ -0,0 +1,574 @@
|
||||
//! `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());
|
||||
}
|
||||
}
|
||||
@@ -12,21 +12,22 @@ use picloud_executor_core::{Engine, Limits};
|
||||
use picloud_manager_core::{
|
||||
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
|
||||
attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
|
||||
files_admin_router, migrations, require_authenticated, route_admin_router, topics_router,
|
||||
triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState,
|
||||
AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository,
|
||||
AppMembersRepository, AppMembersState, AppRepository, AppsState, AuthState, AuthzRepo,
|
||||
DeadLetterRepo, DeadLettersState, Dispatcher, DocsServiceImpl, FilesAdminState, FilesConfig,
|
||||
FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl, KvServiceImpl, OutboxEventEmitter,
|
||||
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
||||
PostgresAppRepository, PostgresAppSecretsRepo, PostgresDeadLetterRepo,
|
||||
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
|
||||
PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
|
||||
PostgresRouteRepository, PostgresScriptRepository, PostgresTopicRepo, PostgresTriggerRepo,
|
||||
PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
|
||||
RouteRepository, SandboxCeiling, ScriptRepository, SubscriberTokenConfig, TopicRepo,
|
||||
TopicsState, TriggerConfig, TriggerRepo, TriggersState,
|
||||
files_admin_router, migrations, require_authenticated, route_admin_router, secrets_router,
|
||||
topics_router, triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository,
|
||||
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
|
||||
AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, AppsState,
|
||||
AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, Dispatcher, DocsServiceImpl,
|
||||
FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl,
|
||||
KvServiceImpl, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo,
|
||||
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
|
||||
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
|
||||
PostgresAppSecretsRepo, PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo,
|
||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo,
|
||||
PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo,
|
||||
PostgresTopicRepo, PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl,
|
||||
RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||
ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig,
|
||||
TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState,
|
||||
};
|
||||
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
|
||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||
@@ -36,9 +37,9 @@ use picloud_orchestrator_core::{
|
||||
};
|
||||
use picloud_shared::{
|
||||
DeadLetterService, DocsService, ExecutionLogSink, FilesService, HttpService, InboxResolver,
|
||||
KvService, OutboxWriter, PubsubService, RealtimeAuthority, RealtimeBroadcaster,
|
||||
ScriptValidator, ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION,
|
||||
WIRE_VERSION,
|
||||
KvService, MasterKey, OutboxWriter, PubsubService, RealtimeAuthority, RealtimeBroadcaster,
|
||||
ScriptValidator, SecretsService, ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION,
|
||||
SDK_VERSION, WIRE_VERSION,
|
||||
};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
@@ -94,7 +95,11 @@ fn read_session_ttl() -> Duration {
|
||||
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
|
||||
/// `/version`) stays open — it's the public ingress for user scripts.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
pub async fn build_app(
|
||||
pool: PgPool,
|
||||
auth: AuthDeps,
|
||||
master_key: MasterKey,
|
||||
) -> anyhow::Result<Router> {
|
||||
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
||||
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
|
||||
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
|
||||
@@ -203,6 +208,20 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
SubscriberTokenConfig::from_env(),
|
||||
),
|
||||
);
|
||||
// v1.1.7 encrypted per-app secrets. Values are AES-256-GCM-sealed
|
||||
// with the process master key before they touch Postgres; the repo
|
||||
// only ever sees ciphertext + nonce. The admin surface reuses the
|
||||
// same repo + master key (see `secrets_state` below).
|
||||
let secrets_config = SecretsConfig::from_env();
|
||||
let secrets_max_value_bytes = secrets_config.max_value_bytes;
|
||||
let secrets_repo: Arc<dyn picloud_manager_core::SecretsRepo> =
|
||||
Arc::new(PostgresSecretsRepo::new(pool.clone()));
|
||||
let secrets: Arc<dyn SecretsService> = Arc::new(SecretsServiceImpl::new(
|
||||
secrets_repo.clone(),
|
||||
authz.clone(),
|
||||
master_key.clone(),
|
||||
secrets_config,
|
||||
));
|
||||
let services = Services::new(
|
||||
kv,
|
||||
docs,
|
||||
@@ -212,6 +231,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
http,
|
||||
files,
|
||||
pubsub,
|
||||
secrets,
|
||||
);
|
||||
let engine = Arc::new(Engine::new(Limits::default(), services));
|
||||
|
||||
@@ -340,6 +360,13 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
authz: authz.clone(),
|
||||
broadcaster: broadcaster.clone(),
|
||||
};
|
||||
let secrets_state = SecretsState {
|
||||
repo: secrets_repo,
|
||||
apps: apps_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
master_key,
|
||||
max_value_bytes: secrets_max_value_bytes,
|
||||
};
|
||||
let apps_state = AppsState {
|
||||
apps: apps_repo,
|
||||
domains: domains_repo,
|
||||
@@ -384,6 +411,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
.merge(triggers_router(triggers_state))
|
||||
.merge(files_admin_router(files_admin_state))
|
||||
.merge(topics_router(topics_state))
|
||||
.merge(secrets_router(secrets_state))
|
||||
.merge(dead_letters_router(dead_letters_state))
|
||||
.layer(from_fn_with_state(
|
||||
auth_state.clone(),
|
||||
|
||||
@@ -39,6 +39,11 @@ async fn run_server() -> anyhow::Result<()> {
|
||||
let database_url =
|
||||
std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL is required"))?;
|
||||
|
||||
// Source the process master key BEFORE doing any work — an unset or
|
||||
// malformed PICLOUD_SECRET_KEY is fatal (v1.1.7). The only escape
|
||||
// hatch is PICLOUD_DEV_MODE=true, which logs a prominent warning.
|
||||
let master_key = picloud_shared::MasterKey::from_env()?;
|
||||
|
||||
let pool = init_db(&database_url).await?;
|
||||
migrations::run(&pool).await?;
|
||||
tracing::info!("migrations applied");
|
||||
@@ -69,7 +74,7 @@ async fn run_server() -> anyhow::Result<()> {
|
||||
// so a delayed sweep can't extend session lifetimes.
|
||||
spawn_session_pruner(auth.sessions.clone());
|
||||
|
||||
let app = build_app(pool, auth).await?;
|
||||
let app = build_app(pool, auth, master_key).await?;
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
tracing::info!(%addr, "picloud all-in-one listening");
|
||||
|
||||
@@ -40,7 +40,13 @@ async fn server_with_app(pool: PgPool) -> (TestServer, String) {
|
||||
.await
|
||||
.expect("seed admin");
|
||||
|
||||
let app = picloud::build_app(pool, auth).await.expect("build_app");
|
||||
let app = picloud::build_app(
|
||||
pool,
|
||||
auth,
|
||||
picloud_shared::MasterKey::from_bytes([0x42u8; 32]),
|
||||
)
|
||||
.await
|
||||
.expect("build_app");
|
||||
let mut server = TestServer::new(app).expect("TestServer should build");
|
||||
|
||||
let resp = server
|
||||
|
||||
@@ -57,9 +57,13 @@ async fn boot(pool: PgPool) -> Seeded {
|
||||
.await
|
||||
.expect("seed owner");
|
||||
|
||||
let app = picloud::build_app(pool.clone(), auth)
|
||||
.await
|
||||
.expect("build_app");
|
||||
let app = picloud::build_app(
|
||||
pool.clone(),
|
||||
auth,
|
||||
picloud_shared::MasterKey::from_bytes([0x42u8; 32]),
|
||||
)
|
||||
.await
|
||||
.expect("build_app");
|
||||
let server = TestServer::new(app).expect("TestServer");
|
||||
|
||||
// Default app id (seeded by migration 0005).
|
||||
|
||||
@@ -67,7 +67,13 @@ async fn server_for(pool: PgPool, suffix: &str) -> (TestServer, String) {
|
||||
.await
|
||||
.expect("seed admin");
|
||||
|
||||
let app = picloud::build_app(pool, auth).await.expect("build_app");
|
||||
let app = picloud::build_app(
|
||||
pool,
|
||||
auth,
|
||||
picloud_shared::MasterKey::from_bytes([0x42u8; 32]),
|
||||
)
|
||||
.await
|
||||
.expect("build_app");
|
||||
let mut server = TestServer::new(app).expect("TestServer");
|
||||
let resp = server
|
||||
.post("/api/v1/admin/auth/login")
|
||||
|
||||
@@ -21,6 +21,10 @@ tokio = { workspace = true, features = ["sync"] }
|
||||
hmac.workspace = true
|
||||
sha2.workspace = true
|
||||
base64.workspace = true
|
||||
# AES-256-GCM envelope + master-key sourcing (v1.1.7 crypto module).
|
||||
aes-gcm.workspace = true
|
||||
rand.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time", "sync"] }
|
||||
|
||||
358
crates/shared/src/crypto.rs
Normal file
358
crates/shared/src/crypto.rs
Normal file
@@ -0,0 +1,358 @@
|
||||
//! AES-256-GCM encryption envelope + master-key sourcing (v1.1.7).
|
||||
//!
|
||||
//! Two responsibilities:
|
||||
//!
|
||||
//! 1. [`encrypt`] / [`decrypt`] — the at-rest envelope used by per-app
|
||||
//! `secrets`, the encrypted `inbound_secret` on email triggers, and
|
||||
//! the realtime signing key. `Aes256Gcm` with a 96-bit (12-byte)
|
||||
//! random nonce and a 128-bit auth tag **appended to the
|
||||
//! ciphertext** (the RustCrypto `Aead`-trait layout — `encrypt`
|
||||
//! returns `ciphertext || tag`, `decrypt` consumes the same). Both
|
||||
//! the ciphertext (tag included) and the nonce are stored.
|
||||
//!
|
||||
//! 2. [`MasterKey`] — the process-wide 32-byte key, sourced once at
|
||||
//! startup from `PICLOUD_SECRET_KEY` (base64 of exactly 32 bytes).
|
||||
//! A deterministic in-memory dev key is allowed ONLY when the env
|
||||
//! var is unset AND `PICLOUD_DEV_MODE=true`; otherwise an unset key
|
||||
//! is fatal (no quiet "your secrets are unencrypted" mode).
|
||||
//!
|
||||
//! **Key rotation is out of scope for v1.1.7.** Changing
|
||||
//! `PICLOUD_SECRET_KEY` between deploys orphans every existing
|
||||
//! ciphertext (it can no longer be decrypted). v1.2+ adds key-version
|
||||
//! columns + a re-encryption pass.
|
||||
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
use aes_gcm::{Aes256Gcm, Key, Nonce};
|
||||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
use base64::Engine as _;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Master-key length in bytes (AES-256 → 32-byte key).
|
||||
pub const KEY_LEN: usize = 32;
|
||||
|
||||
/// GCM nonce length in bytes (96-bit nonce, the AES-GCM standard).
|
||||
pub const NONCE_LEN: usize = 12;
|
||||
|
||||
/// Output of [`encrypt`]: the ciphertext (auth tag appended) plus the
|
||||
/// randomly-generated nonce. Both must be persisted; `decrypt` needs
|
||||
/// the nonce to recover the plaintext.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EncryptResult {
|
||||
/// Ciphertext with the 16-byte GCM auth tag appended.
|
||||
pub ciphertext: Vec<u8>,
|
||||
/// The 12-byte nonce used for this encryption.
|
||||
pub nonce: [u8; NONCE_LEN],
|
||||
}
|
||||
|
||||
/// Errors from the encryption envelope.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CryptoError {
|
||||
/// Authentication failed — wrong key, corrupted ciphertext, or a
|
||||
/// tampered nonce/tag. GCM does not distinguish these (by design),
|
||||
/// so neither do we.
|
||||
#[error("decryption failed: authentication tag mismatch (wrong key, corrupted ciphertext, or tampered nonce)")]
|
||||
Decrypt,
|
||||
|
||||
/// The stored nonce was not exactly [`NONCE_LEN`] bytes — a sign of
|
||||
/// row corruption.
|
||||
#[error("invalid nonce length: expected {NONCE_LEN} bytes, got {0}")]
|
||||
InvalidNonce(usize),
|
||||
}
|
||||
|
||||
/// Encrypt `plaintext` under `key`, generating a fresh random nonce.
|
||||
///
|
||||
/// The auth tag is appended to the returned ciphertext (RustCrypto
|
||||
/// `Aead` layout). Encryption with a valid 32-byte key and 12-byte
|
||||
/// nonce is infallible in `aes-gcm`, so this returns a value rather
|
||||
/// than a `Result`.
|
||||
#[must_use]
|
||||
pub fn encrypt(plaintext: &[u8], key: &[u8; KEY_LEN]) -> EncryptResult {
|
||||
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
|
||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||
// CSPRNG nonce. `thread_rng` is seeded from the OS CSPRNG; a fresh
|
||||
// 96-bit nonce per encryption keeps the (key, nonce) pair unique.
|
||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, plaintext)
|
||||
.expect("AES-256-GCM encryption is infallible for a valid key + 12-byte nonce");
|
||||
EncryptResult {
|
||||
ciphertext,
|
||||
nonce: nonce_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt `ciphertext` (auth tag appended) with the stored `nonce`
|
||||
/// under `key`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CryptoError::InvalidNonce`] if `nonce` is the wrong length,
|
||||
/// or [`CryptoError::Decrypt`] if authentication fails for any reason
|
||||
/// (wrong key, corruption, tampering).
|
||||
pub fn decrypt(
|
||||
ciphertext: &[u8],
|
||||
nonce: &[u8],
|
||||
key: &[u8; KEY_LEN],
|
||||
) -> Result<Vec<u8>, CryptoError> {
|
||||
if nonce.len() != NONCE_LEN {
|
||||
return Err(CryptoError::InvalidNonce(nonce.len()));
|
||||
}
|
||||
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
|
||||
let nonce = Nonce::from_slice(nonce);
|
||||
cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| CryptoError::Decrypt)
|
||||
}
|
||||
|
||||
/// The process-wide master key. Sourced once at startup and threaded
|
||||
/// into the secrets service, the email-trigger receiver, and the
|
||||
/// realtime signing-key migration.
|
||||
///
|
||||
/// Cheap to clone (32 bytes). `Debug` is redacted so the key never
|
||||
/// lands in a log line.
|
||||
#[derive(Clone)]
|
||||
pub struct MasterKey {
|
||||
key: [u8; KEY_LEN],
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MasterKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("MasterKey")
|
||||
.field("key", &"<redacted 32 bytes>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure modes for master-key sourcing. Every variant is a fatal
|
||||
/// startup error — there is no fallback to a quiet plaintext mode.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MasterKeyError {
|
||||
/// `PICLOUD_SECRET_KEY` is unset/empty and dev mode is off.
|
||||
#[error(
|
||||
"PICLOUD_SECRET_KEY is required but unset. Generate one with `openssl rand -base64 32`, \
|
||||
or set PICLOUD_DEV_MODE=true to use an insecure deterministic dev key (never in production)."
|
||||
)]
|
||||
Missing,
|
||||
|
||||
/// `PICLOUD_SECRET_KEY` was not valid base64.
|
||||
#[error("PICLOUD_SECRET_KEY is not valid base64 (expected base64 of 32 bytes — `openssl rand -base64 32`)")]
|
||||
Malformed,
|
||||
|
||||
/// Decoded to the wrong number of bytes.
|
||||
#[error("PICLOUD_SECRET_KEY must decode to exactly {KEY_LEN} bytes, got {0}")]
|
||||
WrongLength(usize),
|
||||
}
|
||||
|
||||
impl MasterKey {
|
||||
/// Borrow the raw 32-byte key for the crypto envelope.
|
||||
#[must_use]
|
||||
pub const fn as_bytes(&self) -> &[u8; KEY_LEN] {
|
||||
&self.key
|
||||
}
|
||||
|
||||
/// Build a key directly from 32 bytes (used by the realtime
|
||||
/// migration's tests and by [`Self::from_base64`]).
|
||||
#[must_use]
|
||||
pub const fn from_bytes(key: [u8; KEY_LEN]) -> Self {
|
||||
Self { key }
|
||||
}
|
||||
|
||||
/// Decode a base64-encoded 32-byte key.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`MasterKeyError::Malformed`] for non-base64 input,
|
||||
/// [`MasterKeyError::WrongLength`] when the decoded length is not 32.
|
||||
pub fn from_base64(s: &str) -> Result<Self, MasterKeyError> {
|
||||
let decoded = B64
|
||||
.decode(s.trim().as_bytes())
|
||||
.map_err(|_| MasterKeyError::Malformed)?;
|
||||
let len = decoded.len();
|
||||
let key: [u8; KEY_LEN] = decoded
|
||||
.try_into()
|
||||
.map_err(|_| MasterKeyError::WrongLength(len))?;
|
||||
Ok(Self { key })
|
||||
}
|
||||
|
||||
/// Source the master key from the process environment per the
|
||||
/// v1.1.7 rules. See [`Self::resolve`] for the decision logic.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates [`MasterKeyError`] when the key is absent (and dev
|
||||
/// mode is off) or malformed.
|
||||
pub fn from_env() -> Result<Self, MasterKeyError> {
|
||||
let secret = std::env::var("PICLOUD_SECRET_KEY").ok();
|
||||
let dev_mode = std::env::var("PICLOUD_DEV_MODE")
|
||||
.map(|v| is_truthy(&v))
|
||||
.unwrap_or(false);
|
||||
Self::resolve(secret.as_deref(), dev_mode)
|
||||
}
|
||||
|
||||
/// Pure resolution logic, factored out of [`Self::from_env`] so it's
|
||||
/// testable without mutating process-global env vars.
|
||||
///
|
||||
/// * `secret` present + non-empty → parse it (fatal if malformed).
|
||||
/// * `secret` absent/empty + `dev_mode` → deterministic dev key +
|
||||
/// a prominent warning.
|
||||
/// * `secret` absent/empty + no dev mode → fatal.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// See [`Self::from_env`].
|
||||
pub fn resolve(secret: Option<&str>, dev_mode: bool) -> Result<Self, MasterKeyError> {
|
||||
match secret {
|
||||
Some(v) if !v.trim().is_empty() => Self::from_base64(v),
|
||||
_ if dev_mode => {
|
||||
tracing::warn!(
|
||||
"PICLOUD_SECRET_KEY is unset and PICLOUD_DEV_MODE=true: using a DETERMINISTIC \
|
||||
in-memory dev master key. At-rest secrets are NOT secure in this mode. \
|
||||
Never run a real deployment without PICLOUD_SECRET_KEY."
|
||||
);
|
||||
Ok(Self::dev_key())
|
||||
}
|
||||
_ => Err(MasterKeyError::Missing),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic dev key: SHA-256 of a fixed label. Stable across
|
||||
/// restarts so dev secrets survive a reboot, but obviously not a
|
||||
/// real secret (the input is public).
|
||||
#[must_use]
|
||||
fn dev_key() -> Self {
|
||||
let digest = Sha256::digest(b"picloud-dev-master-key-v1.1.7");
|
||||
let mut key = [0u8; KEY_LEN];
|
||||
key.copy_from_slice(&digest);
|
||||
Self { key }
|
||||
}
|
||||
}
|
||||
|
||||
/// Common env-var truthiness check shared with the other config knobs.
|
||||
fn is_truthy(v: &str) -> bool {
|
||||
matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_key() -> [u8; KEY_LEN] {
|
||||
let mut k = [0u8; KEY_LEN];
|
||||
for (i, b) in k.iter_mut().enumerate() {
|
||||
*b = u8::try_from(i).unwrap_or(0);
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_recovers_plaintext() {
|
||||
let key = test_key();
|
||||
let plaintext = b"sk_live_super_secret_value";
|
||||
let enc = encrypt(plaintext, &key);
|
||||
let dec = decrypt(&enc.ciphertext, &enc.nonce, &key).unwrap();
|
||||
assert_eq!(dec, plaintext);
|
||||
// Tag is appended → ciphertext is longer than plaintext.
|
||||
assert!(enc.ciphertext.len() > plaintext.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_empty_plaintext() {
|
||||
let key = test_key();
|
||||
let enc = encrypt(b"", &key);
|
||||
let dec = decrypt(&enc.ciphertext, &enc.nonce, &key).unwrap();
|
||||
assert!(dec.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_ciphertext_fails() {
|
||||
let key = test_key();
|
||||
let mut enc = encrypt(b"hello world", &key);
|
||||
enc.ciphertext[0] ^= 0xff;
|
||||
let err = decrypt(&enc.ciphertext, &enc.nonce, &key).unwrap_err();
|
||||
assert!(matches!(err, CryptoError::Decrypt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_nonce_fails() {
|
||||
let key = test_key();
|
||||
let enc = encrypt(b"hello world", &key);
|
||||
let mut nonce = enc.nonce;
|
||||
nonce[0] ^= 0xff;
|
||||
let err = decrypt(&enc.ciphertext, &nonce, &key).unwrap_err();
|
||||
assert!(matches!(err, CryptoError::Decrypt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_fails() {
|
||||
let key = test_key();
|
||||
let mut other = test_key();
|
||||
other[31] ^= 0xff;
|
||||
let enc = encrypt(b"hello world", &key);
|
||||
let err = decrypt(&enc.ciphertext, &enc.nonce, &other).unwrap_err();
|
||||
assert!(matches!(err, CryptoError::Decrypt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_length_nonce_rejected() {
|
||||
let key = test_key();
|
||||
let enc = encrypt(b"hi", &key);
|
||||
let err = decrypt(&enc.ciphertext, &enc.nonce[..8], &key).unwrap_err();
|
||||
assert!(matches!(err, CryptoError::InvalidNonce(8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_nonces_per_encryption() {
|
||||
let key = test_key();
|
||||
let a = encrypt(b"same plaintext", &key);
|
||||
let b = encrypt(b"same plaintext", &key);
|
||||
// Random nonce → ciphertext differs even for identical input.
|
||||
assert_ne!(a.nonce, b.nonce);
|
||||
assert_ne!(a.ciphertext, b.ciphertext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_key_from_valid_base64() {
|
||||
let raw = [7u8; KEY_LEN];
|
||||
let b64 = B64.encode(raw);
|
||||
let mk = MasterKey::from_base64(&b64).unwrap();
|
||||
assert_eq!(mk.as_bytes(), &raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_key_malformed_base64() {
|
||||
let err = MasterKey::from_base64("not valid base64 !!!").unwrap_err();
|
||||
assert!(matches!(err, MasterKeyError::Malformed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_key_wrong_length() {
|
||||
let b64 = B64.encode([1u8; 16]); // 16 bytes, not 32
|
||||
let err = MasterKey::from_base64(&b64).unwrap_err();
|
||||
assert!(matches!(err, MasterKeyError::WrongLength(16)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_missing_without_dev_is_fatal() {
|
||||
let err = MasterKey::resolve(None, false).unwrap_err();
|
||||
assert!(matches!(err, MasterKeyError::Missing));
|
||||
// Empty string counts as missing too.
|
||||
let err = MasterKey::resolve(Some(" "), false).unwrap_err();
|
||||
assert!(matches!(err, MasterKeyError::Missing));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_dev_fallback_only_with_dev_mode() {
|
||||
// Dev mode on + no key → deterministic dev key.
|
||||
let a = MasterKey::resolve(None, true).unwrap();
|
||||
let b = MasterKey::resolve(None, true).unwrap();
|
||||
assert_eq!(a.as_bytes(), b.as_bytes(), "dev key must be deterministic");
|
||||
// A real key always wins over dev mode.
|
||||
let raw = [9u8; KEY_LEN];
|
||||
let real = MasterKey::resolve(Some(&B64.encode(raw)), true).unwrap();
|
||||
assert_eq!(real.as_bytes(), &raw);
|
||||
assert_ne!(real.as_bytes(), a.as_bytes());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod crypto;
|
||||
pub mod dead_letters;
|
||||
pub mod docs;
|
||||
pub mod error;
|
||||
@@ -27,6 +28,7 @@ pub mod route;
|
||||
pub mod sandbox;
|
||||
pub mod script;
|
||||
pub mod sdk_cx;
|
||||
pub mod secrets;
|
||||
pub mod services;
|
||||
pub mod subscriber_token;
|
||||
pub mod trigger_event;
|
||||
@@ -35,6 +37,7 @@ pub mod version;
|
||||
|
||||
pub use app::{App, AppDomain, DomainShape};
|
||||
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
|
||||
pub use crypto::{decrypt, encrypt, CryptoError, EncryptResult, MasterKey, MasterKeyError};
|
||||
pub use dead_letters::{DeadLetterError, DeadLetterId, DeadLetterService, NoopDeadLetterService};
|
||||
pub use docs::{DocId, DocRow, DocsError, DocsListPage, DocsService, NoopDocsService};
|
||||
pub use error::Error;
|
||||
@@ -63,6 +66,10 @@ pub use route::{DispatchMode, HostKind, PathKind, Route};
|
||||
pub use sandbox::ScriptSandbox;
|
||||
pub use script::{Script, ScriptKind};
|
||||
pub use sdk_cx::SdkCallCx;
|
||||
pub use secrets::{
|
||||
validate_secret_name, NoopSecretsService, SecretsError, SecretsListPage, SecretsService,
|
||||
SECRET_NAME_MAX_BYTES,
|
||||
};
|
||||
pub use services::Services;
|
||||
pub use trigger_event::{
|
||||
DeadLetterEventDetail, DocsEventOp, FilesEventOp, KvEventOp, TriggerEvent,
|
||||
|
||||
166
crates/shared/src/secrets.rs
Normal file
166
crates/shared/src/secrets.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
//! `SecretsService` — the v1.1.7 encrypted per-app secrets contract.
|
||||
//!
|
||||
//! Collection-less (per-app, like pubsub): the script API is the bare
|
||||
//! `secrets::{get,set,delete,list}(name)` — there is no
|
||||
//! `secrets::collection(...)`. Secrets are operational config (API keys,
|
||||
//! OAuth tokens, webhook signing keys), encrypted at rest with the
|
||||
//! process master key.
|
||||
//!
|
||||
//! Lives in `picloud-shared` (not `executor-core`) so the Rhai bridge,
|
||||
//! the manager-core Postgres impl, and test fakes can all depend on the
|
||||
//! same trait. Implementations MUST derive every storage `app_id` from
|
||||
//! `cx.app_id` — never from a script-passed argument. That is the
|
||||
//! cross-app isolation boundary; see `docs/sdk-shape.md`.
|
||||
//!
|
||||
//! Values are JSON internally: `set` accepts any `serde_json::Value`
|
||||
//! (the bridge maps a Rhai String/Map/Array to JSON), encrypts the
|
||||
//! encoded bytes, and `get` decrypts + decodes back to the same JSON
|
||||
//! shape — so a String round-trips to a String, not a JSON-quoted
|
||||
//! `"\"…\""`. There is deliberately **no `ServiceEvent` emission**:
|
||||
//! firing triggers on secret writes is a footgun (every rotation would
|
||||
//! fan out handler executions that might log the new value).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::SdkCallCx;
|
||||
|
||||
/// Maximum secret name length in bytes (matches the brief: 255).
|
||||
pub const SECRET_NAME_MAX_BYTES: usize = 255;
|
||||
|
||||
/// `SecretsService` is collection-less and per-app. Every method derives
|
||||
/// the owning `app_id` from `cx.app_id`.
|
||||
#[async_trait]
|
||||
pub trait SecretsService: Send + Sync {
|
||||
/// Decrypt and return the secret, or `None` if no secret with this
|
||||
/// name exists for the app.
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
name: &str,
|
||||
) -> Result<Option<serde_json::Value>, SecretsError>;
|
||||
|
||||
/// Encrypt and store the secret, overwriting any existing value for
|
||||
/// this name.
|
||||
async fn set(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
name: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<(), SecretsError>;
|
||||
|
||||
/// Delete the secret. Returns whether a secret was present.
|
||||
async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError>;
|
||||
|
||||
/// List secret **names only** (never values), cursor-paginated like
|
||||
/// KV/files `list`. `cursor` is opaque; `None` starts from the
|
||||
/// beginning.
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsListPage, SecretsError>;
|
||||
}
|
||||
|
||||
/// One page of secret names from `SecretsService::list`. `next_cursor`
|
||||
/// is `Some` when more pages exist.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SecretsListPage {
|
||||
pub names: Vec<String>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// Failure modes surfaced to the Rhai bridge. The bridge converts each
|
||||
/// to a Rhai runtime error string.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SecretsError {
|
||||
/// Empty name, or a name longer than [`SECRET_NAME_MAX_BYTES`].
|
||||
#[error("{0}")]
|
||||
InvalidName(String),
|
||||
|
||||
/// The encoded plaintext exceeded the configured per-secret cap.
|
||||
#[error("secret value too large: {actual} bytes exceeds the {limit}-byte limit")]
|
||||
TooLarge { limit: usize, actual: usize },
|
||||
|
||||
/// Caller principal lacked the required capability. Only raised when
|
||||
/// `cx.principal.is_some()` — public-HTTP scripts (`principal: None`)
|
||||
/// operate under script-as-gate semantics and skip the check.
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
/// The stored ciphertext could not be decrypted (corrupted row,
|
||||
/// wrong master key, or tampering). The impl logs the affected
|
||||
/// `(app_id, name)` at error level before returning this.
|
||||
#[error("secret is corrupted or was encrypted with a different master key")]
|
||||
Corrupted,
|
||||
|
||||
/// The process master key was unavailable. Startup should already
|
||||
/// have failed; this is defense in depth.
|
||||
#[error("master key is not configured")]
|
||||
MasterKeyMissing,
|
||||
|
||||
/// Anything else — Postgres unavailable, serialization failure, etc.
|
||||
#[error("secrets backend error: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
/// Stub used by the executor-core test harness (which doesn't touch
|
||||
/// secrets) so a `Services` bundle can be built without Postgres. Every
|
||||
/// call returns `SecretsError::Backend(...)` so accidental use surfaces.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NoopSecretsService;
|
||||
|
||||
#[async_trait]
|
||||
impl SecretsService for NoopSecretsService {
|
||||
async fn get(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_name: &str,
|
||||
) -> Result<Option<serde_json::Value>, SecretsError> {
|
||||
Err(SecretsError::Backend("secrets is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_name: &str,
|
||||
_value: serde_json::Value,
|
||||
) -> Result<(), SecretsError> {
|
||||
Err(SecretsError::Backend("secrets is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn delete(&self, _cx: &SdkCallCx, _name: &str) -> Result<bool, SecretsError> {
|
||||
Err(SecretsError::Backend("secrets is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_cursor: Option<&str>,
|
||||
_limit: u32,
|
||||
) -> Result<SecretsListPage, SecretsError> {
|
||||
Err(SecretsError::Backend("secrets is not wired in".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a secret name at the SDK/admin boundary: non-empty and at
|
||||
/// most [`SECRET_NAME_MAX_BYTES`] bytes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SecretsError::InvalidName`] when empty or too long.
|
||||
pub fn validate_secret_name(name: &str) -> Result<(), SecretsError> {
|
||||
if name.is_empty() {
|
||||
return Err(SecretsError::InvalidName(
|
||||
"secret name must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if name.len() > SECRET_NAME_MAX_BYTES {
|
||||
return Err(SecretsError::InvalidName(format!(
|
||||
"secret name must be at most {SECRET_NAME_MAX_BYTES} bytes, got {}",
|
||||
name.len()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -22,7 +22,8 @@ use std::sync::Arc;
|
||||
use crate::{
|
||||
DeadLetterService, DocsService, FilesService, HttpService, KvService, ModuleSource,
|
||||
NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopFilesService, NoopHttpService,
|
||||
NoopKvService, NoopModuleSource, NoopPubsubService, PubsubService, ServiceEventEmitter,
|
||||
NoopKvService, NoopModuleSource, NoopPubsubService, NoopSecretsService, PubsubService,
|
||||
SecretsService, ServiceEventEmitter,
|
||||
};
|
||||
|
||||
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
||||
@@ -73,6 +74,12 @@ pub struct Services {
|
||||
/// publish-time outbox fan-out in the picloud binary;
|
||||
/// `NoopPubsubService` in tests that don't publish.
|
||||
pub pubsub: Arc<dyn PubsubService>,
|
||||
|
||||
/// Encrypted per-app secrets (v1.1.7). Scripts get
|
||||
/// `secrets::{get,set,delete,list}(name)`. Backed by an
|
||||
/// AES-256-GCM-at-rest Postgres repo in the picloud binary;
|
||||
/// `NoopSecretsService` in tests that don't touch secrets.
|
||||
pub secrets: Arc<dyn SecretsService>,
|
||||
}
|
||||
|
||||
impl Services {
|
||||
@@ -90,6 +97,7 @@ impl Services {
|
||||
http: Arc<dyn HttpService>,
|
||||
files: Arc<dyn FilesService>,
|
||||
pubsub: Arc<dyn PubsubService>,
|
||||
secrets: Arc<dyn SecretsService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
kv,
|
||||
@@ -100,6 +108,7 @@ impl Services {
|
||||
http,
|
||||
files,
|
||||
pubsub,
|
||||
secrets,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +128,7 @@ impl Services {
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(NoopFilesService),
|
||||
Arc::new(NoopPubsubService),
|
||||
Arc::new(NoopSecretsService),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +292,11 @@ export interface UpdateTopicInput {
|
||||
auth_mode?: TopicAuthMode;
|
||||
}
|
||||
|
||||
export interface SecretListItem {
|
||||
name: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ExecutionResult {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
@@ -714,6 +719,27 @@ export const api = {
|
||||
)
|
||||
},
|
||||
|
||||
secrets: {
|
||||
// List returns names + last-modified ONLY — values never leave the
|
||||
// server (v1.1.7).
|
||||
list: (idOrSlug: string) =>
|
||||
adminRequest<{ secrets: SecretListItem[]; next_cursor: string | null }>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/secrets`
|
||||
),
|
||||
// `value` is any JSON value; the dashboard sends a single-line
|
||||
// string. Overwrites if the name already exists.
|
||||
set: (idOrSlug: string, name: string, value: unknown) =>
|
||||
adminRequest<null>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/secrets`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, value })
|
||||
}),
|
||||
remove: (idOrSlug: string, name: string) =>
|
||||
adminRequest<null>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/secrets/${encodeURIComponent(name)}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
},
|
||||
|
||||
execute: async (
|
||||
id: string,
|
||||
body: unknown,
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
type Script,
|
||||
type Trigger,
|
||||
type Topic,
|
||||
type TopicAuthMode
|
||||
type TopicAuthMode,
|
||||
type SecretListItem
|
||||
} from '$lib/api';
|
||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||
@@ -27,7 +28,7 @@
|
||||
const SAMPLE_SOURCE =
|
||||
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
||||
|
||||
type Tab = 'scripts' | 'domains' | 'members' | 'settings' | 'triggers' | 'topics';
|
||||
type Tab = 'scripts' | 'domains' | 'members' | 'settings' | 'triggers' | 'topics' | 'secrets';
|
||||
|
||||
// Common IANA timezones offered in the cron form dropdown. Not
|
||||
// exhaustive — the backend validates any IANA name via chrono-tz.
|
||||
@@ -290,6 +291,83 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Secrets tab (v1.1.7). The dashboard only ever sees names +
|
||||
// last-modified — values never leave the server. The create form's
|
||||
// value input is masked by default; revealing it requires a confirm.
|
||||
let secrets = $state<SecretListItem[]>([]);
|
||||
let createSecretName = $state('');
|
||||
let createSecretValue = $state('');
|
||||
let showSecretValue = $state(false);
|
||||
let revealConfirm = $state(false);
|
||||
let creatingSecret = $state(false);
|
||||
let createSecretError = $state<string | null>(null);
|
||||
let secretToRemove = $state<SecretListItem | null>(null);
|
||||
let removingSecret = $state(false);
|
||||
// True when the name already exists — set is an overwrite.
|
||||
const secretNameExists = $derived(
|
||||
secrets.some((s) => s.name === createSecretName.trim())
|
||||
);
|
||||
|
||||
async function loadSecrets(idOrSlug: string) {
|
||||
try {
|
||||
const r = await api.secrets.list(idOrSlug);
|
||||
secrets = r.secrets;
|
||||
} catch {
|
||||
secrets = [];
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShowSecretValue(e: Event) {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target.checked) {
|
||||
// Revealing a secret on screen is sensitive — gate behind a
|
||||
// confirm. Revert the checkbox until the user confirms.
|
||||
target.checked = false;
|
||||
revealConfirm = true;
|
||||
} else {
|
||||
showSecretValue = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRevealSecret() {
|
||||
showSecretValue = true;
|
||||
revealConfirm = false;
|
||||
}
|
||||
|
||||
async function submitCreateSecret(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!app) return;
|
||||
creatingSecret = true;
|
||||
createSecretError = null;
|
||||
try {
|
||||
await api.secrets.set(app.id, createSecretName.trim(), createSecretValue);
|
||||
createSecretName = '';
|
||||
createSecretValue = '';
|
||||
showSecretValue = false;
|
||||
await loadSecrets(app.id);
|
||||
} catch (err) {
|
||||
createSecretError =
|
||||
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
creatingSecret = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRemoveSecret() {
|
||||
if (!app || !secretToRemove) return;
|
||||
removingSecret = true;
|
||||
try {
|
||||
await api.secrets.remove(app.id, secretToRemove.name);
|
||||
secretToRemove = null;
|
||||
await loadSecrets(app.id);
|
||||
} catch (err) {
|
||||
createSecretError =
|
||||
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
removingSecret = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Members tab
|
||||
let eligibleUsers = $state<AdminDto[]>([]);
|
||||
let eligibleLoadError = $state<string | null>(null);
|
||||
@@ -334,7 +412,8 @@
|
||||
loadMembers(app.id),
|
||||
loadEligibleUsers(),
|
||||
loadTriggers(app.id),
|
||||
loadTopics(app.id)
|
||||
loadTopics(app.id),
|
||||
loadSecrets(app.id)
|
||||
);
|
||||
}
|
||||
await Promise.all(loaders);
|
||||
@@ -607,7 +686,8 @@
|
||||
(activeTab === 'settings' ||
|
||||
activeTab === 'members' ||
|
||||
activeTab === 'triggers' ||
|
||||
activeTab === 'topics')
|
||||
activeTab === 'topics' ||
|
||||
activeTab === 'secrets')
|
||||
) {
|
||||
activeTab = 'scripts';
|
||||
}
|
||||
@@ -660,6 +740,11 @@
|
||||
class:active={activeTab === 'topics'}
|
||||
onclick={() => (activeTab = 'topics')}>Topics ({topics.length})</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeTab === 'secrets'}
|
||||
onclick={() => (activeTab = 'secrets')}>Secrets ({secrets.length})</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeTab === 'settings'}
|
||||
@@ -1131,6 +1216,76 @@
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if activeTab === 'secrets' && canAdmin}
|
||||
<section>
|
||||
<h2>Secrets</h2>
|
||||
<p class="muted">
|
||||
Encrypted per-app configuration (API keys, OAuth tokens, webhook signing
|
||||
keys), available to scripts as <code>secrets::get("name")</code>. Values are
|
||||
encrypted at rest with the process master key and
|
||||
<strong>never leave the server</strong> — this list shows names and
|
||||
last-modified times only.
|
||||
</p>
|
||||
|
||||
<form class="create-form" onsubmit={submitCreateSecret}>
|
||||
<div class="row">
|
||||
<label class="grow">
|
||||
<span>Name</span>
|
||||
<input bind:value={createSecretName} required placeholder="stripe_key" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="grow">
|
||||
<span>Value</span>
|
||||
{#if showSecretValue}
|
||||
<input type="text" bind:value={createSecretValue} placeholder="sk_live_…" />
|
||||
{:else}
|
||||
<input type="password" bind:value={createSecretValue} placeholder="sk_live_…" />
|
||||
{/if}
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" checked={showSecretValue} onchange={toggleShowSecretValue} />
|
||||
<span>Show value</span>
|
||||
</label>
|
||||
{#if secretNameExists && createSecretName.trim()}
|
||||
<p class="muted small">
|
||||
A secret named <code>{createSecretName.trim()}</code> already exists — saving
|
||||
overwrites it.
|
||||
</p>
|
||||
{/if}
|
||||
{#if createSecretError}
|
||||
<div class="error">{createSecretError}</div>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={creatingSecret || !createSecretName.trim()}>
|
||||
{creatingSecret ? 'Saving…' : secretNameExists ? 'Overwrite secret' : 'Save secret'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if secrets.length === 0}
|
||||
<p class="muted">No secrets in this app yet.</p>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each secrets as s (s.name)}
|
||||
<li class="domain-row">
|
||||
<div>
|
||||
<code>{s.name}</code>
|
||||
<span class="muted small">· updated {shortDate(s.updated_at)}</span>
|
||||
</div>
|
||||
<div class="topic-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary danger"
|
||||
onclick={() => (secretToRemove = s)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if activeTab === 'settings' && canAdmin}
|
||||
<section>
|
||||
<h2>Settings</h2>
|
||||
@@ -1364,6 +1519,38 @@
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
{/if}
|
||||
|
||||
{#if revealConfirm}
|
||||
<ConfirmModal
|
||||
title="Reveal secret value?"
|
||||
confirmLabel="Show value"
|
||||
onConfirm={confirmRevealSecret}
|
||||
onCancel={() => (revealConfirm = false)}
|
||||
>
|
||||
<p>
|
||||
The value you type will be shown in plain text on screen. Make sure no one
|
||||
is looking over your shoulder and that screen-sharing is off.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
{/if}
|
||||
|
||||
{#if secretToRemove}
|
||||
<ConfirmModal
|
||||
title="Delete secret “{secretToRemove.name}”"
|
||||
variant="danger"
|
||||
confirmLabel="Delete secret"
|
||||
busyLabel="Deleting…"
|
||||
busy={removingSecret}
|
||||
onConfirm={confirmRemoveSecret}
|
||||
onCancel={() => (secretToRemove = null)}
|
||||
>
|
||||
<p>
|
||||
Deleting <code>{secretToRemove.name}</code> is permanent. Any script calling
|
||||
<code>secrets::get("{secretToRemove.name}")</code> will get <code>()</code>
|
||||
until you set it again.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user