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>
154 lines
5.5 KiB
Rust
154 lines
5.5 KiB
Rust
//! `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()
|
|
})
|
|
}
|