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:
MechaCat02
2026-06-04 21:37:17 +02:00
parent dc2e4fa01f
commit 2d11090d1a
28 changed files with 1959 additions and 35 deletions

View File

@@ -19,6 +19,7 @@ pub mod files;
pub mod http;
pub mod kv;
pub mod pubsub;
pub mod secrets;
pub mod stdlib;
pub use bridge::{dynamic_to_json, json_to_dynamic};
@@ -41,5 +42,6 @@ pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCal
dead_letters::register(engine, services, cx.clone());
http::register(engine, services, cx.clone());
files::register(engine, services, cx.clone());
pubsub::register(engine, services, cx);
pubsub::register(engine, services, cx.clone());
secrets::register(engine, services, cx);
}

View 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()
})
}