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 kv;
|
||||
pub mod pubsub;
|
||||
pub mod secrets;
|
||||
pub mod stdlib;
|
||||
|
||||
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
||||
@@ -41,5 +42,6 @@ pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCal
|
||||
dead_letters::register(engine, services, cx.clone());
|
||||
http::register(engine, services, cx.clone());
|
||||
files::register(engine, services, cx.clone());
|
||||
pubsub::register(engine, services, cx);
|
||||
pubsub::register(engine, services, cx.clone());
|
||||
secrets::register(engine, services, cx);
|
||||
}
|
||||
|
||||
153
crates/executor-core/src/sdk/secrets.rs
Normal file
153
crates/executor-core/src/sdk/secrets.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
//! `secrets::` Rhai bridge — encrypted per-app secrets (v1.1.7).
|
||||
//!
|
||||
//! ```rhai
|
||||
//! secrets::set("stripe_key", "sk_live_xxx");
|
||||
//! secrets::set("oauth", #{ client_id: "abc", client_secret: "xyz" });
|
||||
//! let key = secrets::get("stripe_key"); // value or ()
|
||||
//! let removed = secrets::delete("stripe_key"); // bool
|
||||
//! let page = secrets::list(#{ cursor: (), limit: 100 });
|
||||
//! // page = #{ names: [...], next_cursor: () | "..." }
|
||||
//! ```
|
||||
//!
|
||||
//! Collection-less (secrets are per-app, like pubsub topics) so there's
|
||||
//! no `::collection(...)`. Values are any JSON-serializable Rhai value
|
||||
//! (String/Map/Array/number/bool); a String round-trips back as a
|
||||
//! String. `app_id` is derived from `cx.app_id` in the service — it
|
||||
//! never appears in the script-side signature, preserving cross-app
|
||||
//! isolation.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{SdkCallCx, SecretsError, SecretsListPage, Services};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
|
||||
use super::bridge::{dynamic_to_json, json_to_dynamic};
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let svc = services.secrets.clone();
|
||||
let mut module = Module::new();
|
||||
|
||||
// secrets::set(name, value) — overwrites if present.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"set",
|
||||
move |name: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||
let json = dynamic_to_json(&value);
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.set(&cx, name, json).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// secrets::get(name) — decoded value, or () if missing.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"get",
|
||||
move |name: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let opt = block_on(async move { svc.get(&cx, name).await })?;
|
||||
Ok(opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// secrets::delete(name) — bool was-present.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"delete",
|
||||
move |name: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.delete(&cx, name).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// secrets::list(#{ cursor, limit }) — names only, cursor-paginated.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"list",
|
||||
move |opts: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||
let (cursor, limit) = parse_list_opts(&opts)?;
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let page: SecretsListPage =
|
||||
block_on(async move { svc.list(&cx, cursor.as_deref(), limit).await })?;
|
||||
Ok(list_page_to_map(page))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
engine.register_static_module("secrets", module.into());
|
||||
}
|
||||
|
||||
/// Pull `cursor` (string or `()`) and `limit` (int or `()`) out of the
|
||||
/// options map. Unknown/extra keys are ignored.
|
||||
fn parse_list_opts(opts: &Map) -> Result<(Option<String>, u32), Box<EvalAltResult>> {
|
||||
let cursor = match opts.get("cursor") {
|
||||
None => None,
|
||||
Some(d) if d.is_unit() => None,
|
||||
Some(d) if d.is_string() => Some(d.clone().into_string().unwrap_or_default()),
|
||||
Some(_) => return Err(runtime_err("secrets::list: cursor must be a string or ()")),
|
||||
};
|
||||
let limit = match opts.get("limit") {
|
||||
None => 0,
|
||||
Some(d) if d.is_unit() => 0,
|
||||
Some(d) => {
|
||||
let n = d
|
||||
.as_int()
|
||||
.map_err(|_| runtime_err("secrets::list: limit must be an integer or ()"))?;
|
||||
u32::try_from(n.max(0)).unwrap_or(u32::MAX)
|
||||
}
|
||||
};
|
||||
Ok((cursor, limit))
|
||||
}
|
||||
|
||||
fn list_page_to_map(page: SecretsListPage) -> Map {
|
||||
let mut m = Map::new();
|
||||
let names: Array = page.names.into_iter().map(Dynamic::from).collect();
|
||||
m.insert("names".into(), names.into());
|
||||
m.insert(
|
||||
"next_cursor".into(),
|
||||
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
m
|
||||
}
|
||||
|
||||
// Returns the boxed error directly because every caller needs a
|
||||
// `Box<EvalAltResult>` (Rhai's error type), matching the other bridges.
|
||||
#[allow(clippy::unnecessary_box_returns)]
|
||||
fn runtime_err(msg: &str) -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(msg.into(), rhai::Position::NONE).into()
|
||||
}
|
||||
|
||||
/// Run a `SecretsService` future inside the synchronous Rhai context,
|
||||
/// mapping any `SecretsError` to a Rhai runtime error. Mirrors
|
||||
/// `kv::block_on` / `pubsub::block_on`.
|
||||
fn block_on<T, F>(fut: F) -> Result<T, Box<EvalAltResult>>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, SecretsError>> + Send,
|
||||
T: Send,
|
||||
{
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("secrets: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("secrets: {err}").into(), rhai::Position::NONE).into()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user