feat(v1.1.7-secrets): secrets SDK + table + admin API + dashboard
Encrypted per-app secrets, reachable from scripts as
secrets::{get,set,delete,list}(name) and managed from the dashboard
Secrets tab. Values are AES-256-GCM-sealed with the process master key
(picloud_shared::crypto) before they touch Postgres; the repo only ever
sees ciphertext + nonce. JSON round-trip preserves Rhai types.
- migration 0023_secrets.sql (PRIMARY KEY (app_id, name)).
- SecretsService trait (picloud-shared) + SecretsServiceImpl + repo
(manager-core), wired into the Services bundle and Rhai engine.
- Capability::AppSecretsRead/Write (→ script:read / script:write); no
new Scope variants (seven-scope commitment).
- Admin API GET/POST/DELETE /apps/{id}/secrets (list returns names +
updated_at, never values).
- build_app now takes a MasterKey, sourced from PICLOUD_SECRET_KEY in
main.rs; test callers pass a fixed test key.
- 64 KB value cap (PICLOUD_SECRET_MAX_VALUE_BYTES); no ServiceEvent
emission (secret writes don't fire triggers, by design).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ pub mod files;
|
|||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod kv;
|
pub mod kv;
|
||||||
pub mod pubsub;
|
pub mod pubsub;
|
||||||
|
pub mod secrets;
|
||||||
pub mod stdlib;
|
pub mod stdlib;
|
||||||
|
|
||||||
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
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());
|
dead_letters::register(engine, services, cx.clone());
|
||||||
http::register(engine, services, cx.clone());
|
http::register(engine, services, cx.clone());
|
||||||
files::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(NoopHttpService),
|
||||||
Arc::new(picloud_shared::NoopFilesService),
|
Arc::new(picloud_shared::NoopFilesService),
|
||||||
Arc::new(picloud_shared::NoopPubsubService),
|
Arc::new(picloud_shared::NoopPubsubService),
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
);
|
);
|
||||||
let engine = Engine::new(Limits::default(), services);
|
let engine = Engine::new(Limits::default(), services);
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
|
|||||||
Arc::new(NoopHttpService),
|
Arc::new(NoopHttpService),
|
||||||
Arc::new(picloud_shared::NoopFilesService),
|
Arc::new(picloud_shared::NoopFilesService),
|
||||||
Arc::new(picloud_shared::NoopPubsubService),
|
Arc::new(picloud_shared::NoopPubsubService),
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(NoopHttpService),
|
Arc::new(NoopHttpService),
|
||||||
Arc::new(picloud_shared::NoopFilesService),
|
Arc::new(picloud_shared::NoopFilesService),
|
||||||
Arc::new(picloud_shared::NoopPubsubService),
|
Arc::new(picloud_shared::NoopPubsubService),
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(NoopHttpService),
|
Arc::new(NoopHttpService),
|
||||||
Arc::new(InMemoryFiles::default()),
|
Arc::new(InMemoryFiles::default()),
|
||||||
Arc::new(picloud_shared::NoopPubsubService),
|
Arc::new(picloud_shared::NoopPubsubService),
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ fn engine_with(http: Arc<dyn HttpService>) -> Arc<Engine> {
|
|||||||
http,
|
http,
|
||||||
Arc::new(picloud_shared::NoopFilesService),
|
Arc::new(picloud_shared::NoopFilesService),
|
||||||
Arc::new(picloud_shared::NoopPubsubService),
|
Arc::new(picloud_shared::NoopPubsubService),
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(NoopHttpService),
|
Arc::new(NoopHttpService),
|
||||||
Arc::new(picloud_shared::NoopFilesService),
|
Arc::new(picloud_shared::NoopFilesService),
|
||||||
Arc::new(picloud_shared::NoopPubsubService),
|
Arc::new(picloud_shared::NoopPubsubService),
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ fn make_engine(svc: Arc<RecordingPubsub>) -> Arc<Engine> {
|
|||||||
Arc::new(NoopHttpService),
|
Arc::new(NoopHttpService),
|
||||||
Arc::new(NoopFilesService),
|
Arc::new(NoopFilesService),
|
||||||
svc,
|
svc,
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
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(NoopHttpService),
|
||||||
Arc::new(NoopFilesService),
|
Arc::new(NoopFilesService),
|
||||||
Arc::new(FakeMintPubsub),
|
Arc::new(FakeMintPubsub),
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
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
|
/// (v1.1.5). Maps to `script:write` on API keys (a publish is a
|
||||||
/// write that fans out to subscribers). Granted to `editor`+.
|
/// write that fans out to subscribers). Granted to `editor`+.
|
||||||
AppPubsubPublish(AppId),
|
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
|
/// Create / list / delete triggers for this app (v1.1.1). Maps to
|
||||||
/// `app:admin` on API keys — triggers are app-configuration acts
|
/// `app:admin` on API keys — triggers are app-configuration acts
|
||||||
/// rather than data-plane access. Granted to `app_admin`+.
|
/// rather than data-plane access. Granted to `app_admin`+.
|
||||||
@@ -128,6 +136,8 @@ impl Capability {
|
|||||||
| Self::AppFilesRead(id)
|
| Self::AppFilesRead(id)
|
||||||
| Self::AppFilesWrite(id)
|
| Self::AppFilesWrite(id)
|
||||||
| Self::AppPubsubPublish(id)
|
| Self::AppPubsubPublish(id)
|
||||||
|
| Self::AppSecretsRead(id)
|
||||||
|
| Self::AppSecretsWrite(id)
|
||||||
| Self::AppManageTriggers(id)
|
| Self::AppManageTriggers(id)
|
||||||
| Self::AppDeadLetterManage(id)
|
| Self::AppDeadLetterManage(id)
|
||||||
| Self::AppTopicManage(id) => Some(id),
|
| Self::AppTopicManage(id) => Some(id),
|
||||||
@@ -148,13 +158,15 @@ impl Capability {
|
|||||||
Self::AppRead(_)
|
Self::AppRead(_)
|
||||||
| Self::AppKvRead(_)
|
| Self::AppKvRead(_)
|
||||||
| Self::AppDocsRead(_)
|
| Self::AppDocsRead(_)
|
||||||
| Self::AppFilesRead(_) => Scope::ScriptRead,
|
| Self::AppFilesRead(_)
|
||||||
|
| Self::AppSecretsRead(_) => Scope::ScriptRead,
|
||||||
Self::AppWriteScript(_)
|
Self::AppWriteScript(_)
|
||||||
| Self::AppKvWrite(_)
|
| Self::AppKvWrite(_)
|
||||||
| Self::AppDocsWrite(_)
|
| Self::AppDocsWrite(_)
|
||||||
| Self::AppHttpRequest(_)
|
| Self::AppHttpRequest(_)
|
||||||
| Self::AppFilesWrite(_)
|
| Self::AppFilesWrite(_)
|
||||||
| Self::AppPubsubPublish(_) => Scope::ScriptWrite,
|
| Self::AppPubsubPublish(_)
|
||||||
|
| Self::AppSecretsWrite(_) => Scope::ScriptWrite,
|
||||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||||
Self::AppAdmin(_)
|
Self::AppAdmin(_)
|
||||||
@@ -305,6 +317,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
|||||||
| Capability::AppKvRead(_)
|
| Capability::AppKvRead(_)
|
||||||
| Capability::AppDocsRead(_)
|
| Capability::AppDocsRead(_)
|
||||||
| Capability::AppFilesRead(_)
|
| Capability::AppFilesRead(_)
|
||||||
|
| Capability::AppSecretsRead(_)
|
||||||
);
|
);
|
||||||
let in_editor = in_viewer
|
let in_editor = in_viewer
|
||||||
|| matches!(
|
|| matches!(
|
||||||
@@ -316,6 +329,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
|||||||
| Capability::AppHttpRequest(_)
|
| Capability::AppHttpRequest(_)
|
||||||
| Capability::AppFilesWrite(_)
|
| Capability::AppFilesWrite(_)
|
||||||
| Capability::AppPubsubPublish(_)
|
| Capability::AppPubsubPublish(_)
|
||||||
|
| Capability::AppSecretsWrite(_)
|
||||||
);
|
);
|
||||||
let in_app_admin = in_editor
|
let in_app_admin = in_editor
|
||||||
|| matches!(
|
|| matches!(
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ pub mod route_admin;
|
|||||||
pub mod route_repo;
|
pub mod route_repo;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
|
pub mod secrets_api;
|
||||||
|
pub mod secrets_repo;
|
||||||
|
pub mod secrets_service;
|
||||||
pub mod ssrf;
|
pub mod ssrf;
|
||||||
pub mod topic_repo;
|
pub mod topic_repo;
|
||||||
pub mod topics_api;
|
pub mod topics_api;
|
||||||
@@ -134,6 +137,15 @@ pub use repo::{
|
|||||||
pub use route_admin::{compile_routes, route_admin_router, RouteAdminState};
|
pub use route_admin::{compile_routes, route_admin_router, RouteAdminState};
|
||||||
pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
|
pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
|
||||||
pub use sandbox::{CeilingError, SandboxCeiling};
|
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 topic_repo::{PostgresTopicRepo, Topic, TopicAuthMode, TopicRepo, TopicRepoError};
|
||||||
pub use topics_api::{topics_router, TopicsApiError, TopicsState};
|
pub use topics_api::{topics_router, TopicsApiError, TopicsState};
|
||||||
pub use trigger_config::{BackoffShape, TriggerConfig};
|
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::{
|
use picloud_manager_core::{
|
||||||
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
|
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,
|
attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
|
||||||
files_admin_router, migrations, require_authenticated, route_admin_router, topics_router,
|
files_admin_router, migrations, require_authenticated, route_admin_router, secrets_router,
|
||||||
triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState,
|
topics_router, triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository,
|
||||||
AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository,
|
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
|
||||||
AppMembersRepository, AppMembersState, AppRepository, AppsState, AuthState, AuthzRepo,
|
AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, AppsState,
|
||||||
DeadLetterRepo, DeadLettersState, Dispatcher, DocsServiceImpl, FilesAdminState, FilesConfig,
|
AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, Dispatcher, DocsServiceImpl,
|
||||||
FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl, KvServiceImpl, OutboxEventEmitter,
|
FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl,
|
||||||
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
KvServiceImpl, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo,
|
||||||
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
|
||||||
PostgresAppRepository, PostgresAppSecretsRepo, PostgresDeadLetterRepo,
|
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
|
||||||
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
|
PostgresAppSecretsRepo, PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo,
|
||||||
PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
|
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo,
|
||||||
PostgresRouteRepository, PostgresScriptRepository, PostgresTopicRepo, PostgresTriggerRepo,
|
PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo,
|
||||||
PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
|
PostgresTopicRepo, PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl,
|
||||||
RouteRepository, SandboxCeiling, ScriptRepository, SubscriberTokenConfig, TopicRepo,
|
RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||||
TopicsState, TriggerConfig, TriggerRepo, TriggersState,
|
ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig,
|
||||||
|
TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
|
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
|
||||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||||
@@ -36,9 +37,9 @@ use picloud_orchestrator_core::{
|
|||||||
};
|
};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
DeadLetterService, DocsService, ExecutionLogSink, FilesService, HttpService, InboxResolver,
|
DeadLetterService, DocsService, ExecutionLogSink, FilesService, HttpService, InboxResolver,
|
||||||
KvService, OutboxWriter, PubsubService, RealtimeAuthority, RealtimeBroadcaster,
|
KvService, MasterKey, OutboxWriter, PubsubService, RealtimeAuthority, RealtimeBroadcaster,
|
||||||
ScriptValidator, ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION,
|
ScriptValidator, SecretsService, ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION,
|
||||||
WIRE_VERSION,
|
SDK_VERSION, WIRE_VERSION,
|
||||||
};
|
};
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -94,7 +95,11 @@ fn read_session_ttl() -> Duration {
|
|||||||
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
|
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
|
||||||
/// `/version`) stays open — it's the public ingress for user scripts.
|
/// `/version`) stays open — it's the public ingress for user scripts.
|
||||||
#[allow(clippy::too_many_lines)]
|
#[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 script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
||||||
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
|
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
|
||||||
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::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(),
|
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(
|
let services = Services::new(
|
||||||
kv,
|
kv,
|
||||||
docs,
|
docs,
|
||||||
@@ -212,6 +231,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
http,
|
http,
|
||||||
files,
|
files,
|
||||||
pubsub,
|
pubsub,
|
||||||
|
secrets,
|
||||||
);
|
);
|
||||||
let engine = Arc::new(Engine::new(Limits::default(), services));
|
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(),
|
authz: authz.clone(),
|
||||||
broadcaster: broadcaster.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 {
|
let apps_state = AppsState {
|
||||||
apps: apps_repo,
|
apps: apps_repo,
|
||||||
domains: domains_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(triggers_router(triggers_state))
|
||||||
.merge(files_admin_router(files_admin_state))
|
.merge(files_admin_router(files_admin_state))
|
||||||
.merge(topics_router(topics_state))
|
.merge(topics_router(topics_state))
|
||||||
|
.merge(secrets_router(secrets_state))
|
||||||
.merge(dead_letters_router(dead_letters_state))
|
.merge(dead_letters_router(dead_letters_state))
|
||||||
.layer(from_fn_with_state(
|
.layer(from_fn_with_state(
|
||||||
auth_state.clone(),
|
auth_state.clone(),
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ async fn run_server() -> anyhow::Result<()> {
|
|||||||
let database_url =
|
let database_url =
|
||||||
std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL is required"))?;
|
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?;
|
let pool = init_db(&database_url).await?;
|
||||||
migrations::run(&pool).await?;
|
migrations::run(&pool).await?;
|
||||||
tracing::info!("migrations applied");
|
tracing::info!("migrations applied");
|
||||||
@@ -69,7 +74,7 @@ async fn run_server() -> anyhow::Result<()> {
|
|||||||
// so a delayed sweep can't extend session lifetimes.
|
// so a delayed sweep can't extend session lifetimes.
|
||||||
spawn_session_pruner(auth.sessions.clone());
|
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?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
tracing::info!(%addr, "picloud all-in-one listening");
|
tracing::info!(%addr, "picloud all-in-one listening");
|
||||||
|
|||||||
@@ -40,7 +40,13 @@ async fn server_with_app(pool: PgPool) -> (TestServer, String) {
|
|||||||
.await
|
.await
|
||||||
.expect("seed admin");
|
.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 mut server = TestServer::new(app).expect("TestServer should build");
|
||||||
|
|
||||||
let resp = server
|
let resp = server
|
||||||
|
|||||||
@@ -57,9 +57,13 @@ async fn boot(pool: PgPool) -> Seeded {
|
|||||||
.await
|
.await
|
||||||
.expect("seed owner");
|
.expect("seed owner");
|
||||||
|
|
||||||
let app = picloud::build_app(pool.clone(), auth)
|
let app = picloud::build_app(
|
||||||
.await
|
pool.clone(),
|
||||||
.expect("build_app");
|
auth,
|
||||||
|
picloud_shared::MasterKey::from_bytes([0x42u8; 32]),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("build_app");
|
||||||
let server = TestServer::new(app).expect("TestServer");
|
let server = TestServer::new(app).expect("TestServer");
|
||||||
|
|
||||||
// Default app id (seeded by migration 0005).
|
// Default app id (seeded by migration 0005).
|
||||||
|
|||||||
@@ -67,7 +67,13 @@ async fn server_for(pool: PgPool, suffix: &str) -> (TestServer, String) {
|
|||||||
.await
|
.await
|
||||||
.expect("seed admin");
|
.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 mut server = TestServer::new(app).expect("TestServer");
|
||||||
let resp = server
|
let resp = server
|
||||||
.post("/api/v1/admin/auth/login")
|
.post("/api/v1/admin/auth/login")
|
||||||
|
|||||||
@@ -92,7 +92,11 @@ pub fn encrypt(plaintext: &[u8], key: &[u8; KEY_LEN]) -> EncryptResult {
|
|||||||
/// Returns [`CryptoError::InvalidNonce`] if `nonce` is the wrong length,
|
/// Returns [`CryptoError::InvalidNonce`] if `nonce` is the wrong length,
|
||||||
/// or [`CryptoError::Decrypt`] if authentication fails for any reason
|
/// or [`CryptoError::Decrypt`] if authentication fails for any reason
|
||||||
/// (wrong key, corruption, tampering).
|
/// (wrong key, corruption, tampering).
|
||||||
pub fn decrypt(ciphertext: &[u8], nonce: &[u8], key: &[u8; KEY_LEN]) -> Result<Vec<u8>, CryptoError> {
|
pub fn decrypt(
|
||||||
|
ciphertext: &[u8],
|
||||||
|
nonce: &[u8],
|
||||||
|
key: &[u8; KEY_LEN],
|
||||||
|
) -> Result<Vec<u8>, CryptoError> {
|
||||||
if nonce.len() != NONCE_LEN {
|
if nonce.len() != NONCE_LEN {
|
||||||
return Err(CryptoError::InvalidNonce(nonce.len()));
|
return Err(CryptoError::InvalidNonce(nonce.len()));
|
||||||
}
|
}
|
||||||
@@ -238,7 +242,7 @@ mod tests {
|
|||||||
fn test_key() -> [u8; KEY_LEN] {
|
fn test_key() -> [u8; KEY_LEN] {
|
||||||
let mut k = [0u8; KEY_LEN];
|
let mut k = [0u8; KEY_LEN];
|
||||||
for (i, b) in k.iter_mut().enumerate() {
|
for (i, b) in k.iter_mut().enumerate() {
|
||||||
*b = i as u8;
|
*b = u8::try_from(i).unwrap_or(0);
|
||||||
}
|
}
|
||||||
k
|
k
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ pub mod route;
|
|||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
pub mod script;
|
pub mod script;
|
||||||
pub mod sdk_cx;
|
pub mod sdk_cx;
|
||||||
|
pub mod secrets;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
pub mod subscriber_token;
|
pub mod subscriber_token;
|
||||||
pub mod trigger_event;
|
pub mod trigger_event;
|
||||||
@@ -65,6 +66,10 @@ pub use route::{DispatchMode, HostKind, PathKind, Route};
|
|||||||
pub use sandbox::ScriptSandbox;
|
pub use sandbox::ScriptSandbox;
|
||||||
pub use script::{Script, ScriptKind};
|
pub use script::{Script, ScriptKind};
|
||||||
pub use sdk_cx::SdkCallCx;
|
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 services::Services;
|
||||||
pub use trigger_event::{
|
pub use trigger_event::{
|
||||||
DeadLetterEventDetail, DocsEventOp, FilesEventOp, KvEventOp, TriggerEvent,
|
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::{
|
use crate::{
|
||||||
DeadLetterService, DocsService, FilesService, HttpService, KvService, ModuleSource,
|
DeadLetterService, DocsService, FilesService, HttpService, KvService, ModuleSource,
|
||||||
NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopFilesService, NoopHttpService,
|
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
|
/// 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;
|
/// publish-time outbox fan-out in the picloud binary;
|
||||||
/// `NoopPubsubService` in tests that don't publish.
|
/// `NoopPubsubService` in tests that don't publish.
|
||||||
pub pubsub: Arc<dyn PubsubService>,
|
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 {
|
impl Services {
|
||||||
@@ -90,6 +97,7 @@ impl Services {
|
|||||||
http: Arc<dyn HttpService>,
|
http: Arc<dyn HttpService>,
|
||||||
files: Arc<dyn FilesService>,
|
files: Arc<dyn FilesService>,
|
||||||
pubsub: Arc<dyn PubsubService>,
|
pubsub: Arc<dyn PubsubService>,
|
||||||
|
secrets: Arc<dyn SecretsService>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
kv,
|
kv,
|
||||||
@@ -100,6 +108,7 @@ impl Services {
|
|||||||
http,
|
http,
|
||||||
files,
|
files,
|
||||||
pubsub,
|
pubsub,
|
||||||
|
secrets,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +128,7 @@ impl Services {
|
|||||||
Arc::new(NoopHttpService),
|
Arc::new(NoopHttpService),
|
||||||
Arc::new(NoopFilesService),
|
Arc::new(NoopFilesService),
|
||||||
Arc::new(NoopPubsubService),
|
Arc::new(NoopPubsubService),
|
||||||
|
Arc::new(NoopSecretsService),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,6 +292,11 @@ export interface UpdateTopicInput {
|
|||||||
auth_mode?: TopicAuthMode;
|
auth_mode?: TopicAuthMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SecretListItem {
|
||||||
|
name: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExecutionResult {
|
export interface ExecutionResult {
|
||||||
status: number;
|
status: number;
|
||||||
headers: Record<string, string>;
|
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 (
|
execute: async (
|
||||||
id: string,
|
id: string,
|
||||||
body: unknown,
|
body: unknown,
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
type Script,
|
type Script,
|
||||||
type Trigger,
|
type Trigger,
|
||||||
type Topic,
|
type Topic,
|
||||||
type TopicAuthMode
|
type TopicAuthMode,
|
||||||
|
type SecretListItem
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||||
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
const SAMPLE_SOURCE =
|
const SAMPLE_SOURCE =
|
||||||
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
'#{\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
|
// Common IANA timezones offered in the cron form dropdown. Not
|
||||||
// exhaustive — the backend validates any IANA name via chrono-tz.
|
// 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
|
// Members tab
|
||||||
let eligibleUsers = $state<AdminDto[]>([]);
|
let eligibleUsers = $state<AdminDto[]>([]);
|
||||||
let eligibleLoadError = $state<string | null>(null);
|
let eligibleLoadError = $state<string | null>(null);
|
||||||
@@ -334,7 +412,8 @@
|
|||||||
loadMembers(app.id),
|
loadMembers(app.id),
|
||||||
loadEligibleUsers(),
|
loadEligibleUsers(),
|
||||||
loadTriggers(app.id),
|
loadTriggers(app.id),
|
||||||
loadTopics(app.id)
|
loadTopics(app.id),
|
||||||
|
loadSecrets(app.id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await Promise.all(loaders);
|
await Promise.all(loaders);
|
||||||
@@ -607,7 +686,8 @@
|
|||||||
(activeTab === 'settings' ||
|
(activeTab === 'settings' ||
|
||||||
activeTab === 'members' ||
|
activeTab === 'members' ||
|
||||||
activeTab === 'triggers' ||
|
activeTab === 'triggers' ||
|
||||||
activeTab === 'topics')
|
activeTab === 'topics' ||
|
||||||
|
activeTab === 'secrets')
|
||||||
) {
|
) {
|
||||||
activeTab = 'scripts';
|
activeTab = 'scripts';
|
||||||
}
|
}
|
||||||
@@ -660,6 +740,11 @@
|
|||||||
class:active={activeTab === 'topics'}
|
class:active={activeTab === 'topics'}
|
||||||
onclick={() => (activeTab = 'topics')}>Topics ({topics.length})</button
|
onclick={() => (activeTab = 'topics')}>Topics ({topics.length})</button
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeTab === 'secrets'}
|
||||||
|
onclick={() => (activeTab = 'secrets')}>Secrets ({secrets.length})</button
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:active={activeTab === 'settings'}
|
class:active={activeTab === 'settings'}
|
||||||
@@ -1131,6 +1216,76 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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}
|
{:else if activeTab === 'settings' && canAdmin}
|
||||||
<section>
|
<section>
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
@@ -1364,6 +1519,38 @@
|
|||||||
</p>
|
</p>
|
||||||
</ConfirmModal>
|
</ConfirmModal>
|
||||||
{/if}
|
{/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}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
Reference in New Issue
Block a user