From 2d11090d1a99383f17637f8ab9911a7c5f36f4fc Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 4 Jun 2026 21:37:17 +0200 Subject: [PATCH] feat(v1.1.7-secrets): secrets SDK + table + admin API + dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/executor-core/src/sdk/mod.rs | 4 +- crates/executor-core/src/sdk/secrets.rs | 153 +++++ .../tests/module_redaction_logging.rs | 1 + crates/executor-core/tests/modules.rs | 1 + crates/executor-core/tests/sdk_docs.rs | 1 + crates/executor-core/tests/sdk_files.rs | 1 + crates/executor-core/tests/sdk_http.rs | 1 + crates/executor-core/tests/sdk_kv.rs | 1 + crates/executor-core/tests/sdk_pubsub.rs | 1 + crates/executor-core/tests/sdk_secrets.rs | 212 +++++++ .../tests/sdk_subscriber_token.rs | 1 + .../manager-core/migrations/0023_secrets.sql | 24 + crates/manager-core/src/authz.rs | 18 +- crates/manager-core/src/lib.rs | 12 + crates/manager-core/src/secrets_api.rs | 232 +++++++ crates/manager-core/src/secrets_repo.rs | 246 ++++++++ crates/manager-core/src/secrets_service.rs | 574 ++++++++++++++++++ crates/picloud/src/lib.rs | 66 +- crates/picloud/src/main.rs | 7 +- crates/picloud/tests/api.rs | 8 +- crates/picloud/tests/authz.rs | 10 +- crates/picloud/tests/dispatcher_e2e.rs | 8 +- crates/shared/src/crypto.rs | 8 +- crates/shared/src/lib.rs | 5 + crates/shared/src/secrets.rs | 166 +++++ crates/shared/src/services.rs | 12 +- dashboard/src/lib/api.ts | 26 + dashboard/src/routes/apps/[slug]/+page.svelte | 195 +++++- 28 files changed, 1959 insertions(+), 35 deletions(-) create mode 100644 crates/executor-core/src/sdk/secrets.rs create mode 100644 crates/executor-core/tests/sdk_secrets.rs create mode 100644 crates/manager-core/migrations/0023_secrets.sql create mode 100644 crates/manager-core/src/secrets_api.rs create mode 100644 crates/manager-core/src/secrets_repo.rs create mode 100644 crates/manager-core/src/secrets_service.rs create mode 100644 crates/shared/src/secrets.rs diff --git a/crates/executor-core/src/sdk/mod.rs b/crates/executor-core/src/sdk/mod.rs index 74a319c..5a17fb2 100644 --- a/crates/executor-core/src/sdk/mod.rs +++ b/crates/executor-core/src/sdk/mod.rs @@ -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) { + 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> { + 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> { + 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> { + 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> { + 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, u32), Box> { + 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` (Rhai's error type), matching the other bridges. +#[allow(clippy::unnecessary_box_returns)] +fn runtime_err(msg: &str) -> Box { + 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(fut: F) -> Result> +where + F: std::future::Future> + Send, + T: Send, +{ + let handle = TokioHandle::try_current().map_err(|e| -> Box { + EvalAltResult::ErrorRuntime( + format!("secrets: no tokio runtime available: {e}").into(), + rhai::Position::NONE, + ) + .into() + })?; + handle.block_on(fut).map_err(|err| -> Box { + EvalAltResult::ErrorRuntime(format!("secrets: {err}").into(), rhai::Position::NONE).into() + }) +} diff --git a/crates/executor-core/tests/module_redaction_logging.rs b/crates/executor-core/tests/module_redaction_logging.rs index f39e7b7..9bc9224 100644 --- a/crates/executor-core/tests/module_redaction_logging.rs +++ b/crates/executor-core/tests/module_redaction_logging.rs @@ -101,6 +101,7 @@ async fn original_backend_error_is_logged_at_error_level() { Arc::new(NoopHttpService), Arc::new(picloud_shared::NoopFilesService), Arc::new(picloud_shared::NoopPubsubService), + Arc::new(picloud_shared::NoopSecretsService), ); let engine = Engine::new(Limits::default(), services); diff --git a/crates/executor-core/tests/modules.rs b/crates/executor-core/tests/modules.rs index 94d8f05..78f7bc4 100644 --- a/crates/executor-core/tests/modules.rs +++ b/crates/executor-core/tests/modules.rs @@ -99,6 +99,7 @@ fn services_with(modules: Arc) -> Services { Arc::new(NoopHttpService), Arc::new(picloud_shared::NoopFilesService), Arc::new(picloud_shared::NoopPubsubService), + Arc::new(picloud_shared::NoopSecretsService), ) } diff --git a/crates/executor-core/tests/sdk_docs.rs b/crates/executor-core/tests/sdk_docs.rs index 6b2d76c..5050dfe 100644 --- a/crates/executor-core/tests/sdk_docs.rs +++ b/crates/executor-core/tests/sdk_docs.rs @@ -230,6 +230,7 @@ fn make_engine() -> Arc { Arc::new(NoopHttpService), Arc::new(picloud_shared::NoopFilesService), Arc::new(picloud_shared::NoopPubsubService), + Arc::new(picloud_shared::NoopSecretsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_files.rs b/crates/executor-core/tests/sdk_files.rs index 9e29f0c..d253347 100644 --- a/crates/executor-core/tests/sdk_files.rs +++ b/crates/executor-core/tests/sdk_files.rs @@ -167,6 +167,7 @@ fn make_engine() -> Arc { Arc::new(NoopHttpService), Arc::new(InMemoryFiles::default()), Arc::new(picloud_shared::NoopPubsubService), + Arc::new(picloud_shared::NoopSecretsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_http.rs b/crates/executor-core/tests/sdk_http.rs index c44510f..567bbc8 100644 --- a/crates/executor-core/tests/sdk_http.rs +++ b/crates/executor-core/tests/sdk_http.rs @@ -90,6 +90,7 @@ fn engine_with(http: Arc) -> Arc { http, Arc::new(picloud_shared::NoopFilesService), Arc::new(picloud_shared::NoopPubsubService), + Arc::new(picloud_shared::NoopSecretsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_kv.rs b/crates/executor-core/tests/sdk_kv.rs index ce8f51f..6193dc8 100644 --- a/crates/executor-core/tests/sdk_kv.rs +++ b/crates/executor-core/tests/sdk_kv.rs @@ -109,6 +109,7 @@ fn make_engine() -> Arc { Arc::new(NoopHttpService), Arc::new(picloud_shared::NoopFilesService), Arc::new(picloud_shared::NoopPubsubService), + Arc::new(picloud_shared::NoopSecretsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_pubsub.rs b/crates/executor-core/tests/sdk_pubsub.rs index 9c64252..41a2e6c 100644 --- a/crates/executor-core/tests/sdk_pubsub.rs +++ b/crates/executor-core/tests/sdk_pubsub.rs @@ -47,6 +47,7 @@ fn make_engine(svc: Arc) -> Arc { Arc::new(NoopHttpService), Arc::new(NoopFilesService), svc, + Arc::new(picloud_shared::NoopSecretsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_secrets.rs b/crates/executor-core/tests/sdk_secrets.rs new file mode 100644 index 0000000..bb4e69c --- /dev/null +++ b/crates/executor-core/tests/sdk_secrets.rs @@ -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>, +} + +#[async_trait] +impl SecretsService for InMemorySecrets { + async fn get(&self, cx: &SdkCallCx, name: &str) -> Result, 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 { + 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 { + let data = self.data.lock().await; + let mut names: Vec = 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 { + 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, 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"); +} diff --git a/crates/executor-core/tests/sdk_subscriber_token.rs b/crates/executor-core/tests/sdk_subscriber_token.rs index 0768c2c..8b89c2c 100644 --- a/crates/executor-core/tests/sdk_subscriber_token.rs +++ b/crates/executor-core/tests/sdk_subscriber_token.rs @@ -94,6 +94,7 @@ fn make_engine() -> Arc { Arc::new(NoopHttpService), Arc::new(NoopFilesService), Arc::new(FakeMintPubsub), + Arc::new(picloud_shared::NoopSecretsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/manager-core/migrations/0023_secrets.sql b/crates/manager-core/migrations/0023_secrets.sql new file mode 100644 index 0000000..c5d868b --- /dev/null +++ b/crates/manager-core/migrations/0023_secrets.sql @@ -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); diff --git a/crates/manager-core/src/authz.rs b/crates/manager-core/src/authz.rs index b7844d2..b8d6505 100644 --- a/crates/manager-core/src/authz.rs +++ b/crates/manager-core/src/authz.rs @@ -89,6 +89,14 @@ pub enum Capability { /// (v1.1.5). Maps to `script:write` on API keys (a publish is a /// write that fans out to subscribers). Granted to `editor`+. AppPubsubPublish(AppId), + /// Read a decrypted secret from this app's secrets store (v1.1.7). + /// Same trust shape as KV/docs/files read — granted to `viewer`+, + /// maps to `script:read` on API keys. Honors the seven-scope + /// commitment. + AppSecretsRead(AppId), + /// Write (set/delete) a secret in this app's secrets store (v1.1.7). + /// Granted to `editor`+, maps to `script:write` on API keys. + AppSecretsWrite(AppId), /// Create / list / delete triggers for this app (v1.1.1). Maps to /// `app:admin` on API keys — triggers are app-configuration acts /// rather than data-plane access. Granted to `app_admin`+. @@ -128,6 +136,8 @@ impl Capability { | Self::AppFilesRead(id) | Self::AppFilesWrite(id) | Self::AppPubsubPublish(id) + | Self::AppSecretsRead(id) + | Self::AppSecretsWrite(id) | Self::AppManageTriggers(id) | Self::AppDeadLetterManage(id) | Self::AppTopicManage(id) => Some(id), @@ -148,13 +158,15 @@ impl Capability { Self::AppRead(_) | Self::AppKvRead(_) | Self::AppDocsRead(_) - | Self::AppFilesRead(_) => Scope::ScriptRead, + | Self::AppFilesRead(_) + | Self::AppSecretsRead(_) => Scope::ScriptRead, Self::AppWriteScript(_) | Self::AppKvWrite(_) | Self::AppDocsWrite(_) | Self::AppHttpRequest(_) | Self::AppFilesWrite(_) - | Self::AppPubsubPublish(_) => Scope::ScriptWrite, + | Self::AppPubsubPublish(_) + | Self::AppSecretsWrite(_) => Scope::ScriptWrite, Self::AppWriteRoute(_) => Scope::RouteWrite, Self::AppManageDomains(_) => Scope::DomainManage, Self::AppAdmin(_) @@ -305,6 +317,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool { | Capability::AppKvRead(_) | Capability::AppDocsRead(_) | Capability::AppFilesRead(_) + | Capability::AppSecretsRead(_) ); let in_editor = in_viewer || matches!( @@ -316,6 +329,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool { | Capability::AppHttpRequest(_) | Capability::AppFilesWrite(_) | Capability::AppPubsubPublish(_) + | Capability::AppSecretsWrite(_) ); let in_app_admin = in_editor || matches!( diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 561594d..feefe9f 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -53,6 +53,9 @@ pub mod route_admin; pub mod route_repo; pub mod sandbox; pub mod scheduler; +pub mod secrets_api; +pub mod secrets_repo; +pub mod secrets_service; pub mod ssrf; pub mod topic_repo; pub mod topics_api; @@ -134,6 +137,15 @@ pub use repo::{ pub use route_admin::{compile_routes, route_admin_router, RouteAdminState}; pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository}; pub use sandbox::{CeilingError, SandboxCeiling}; +pub use secrets_api::{secrets_router, SecretsApiError, SecretsState}; +pub use secrets_repo::{ + PostgresSecretsRepo, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo, + SecretsRepoError, StoredSecret, +}; +pub use secrets_service::{ + open as open_secret, seal as seal_secret, SecretsConfig, SecretsServiceImpl, + DEFAULT_SECRET_MAX_VALUE_BYTES, +}; pub use topic_repo::{PostgresTopicRepo, Topic, TopicAuthMode, TopicRepo, TopicRepoError}; pub use topics_api::{topics_router, TopicsApiError, TopicsState}; pub use trigger_config::{BackoffShape, TriggerConfig}; diff --git a/crates/manager-core/src/secrets_api.rs b/crates/manager-core/src/secrets_api.rs new file mode 100644 index 0000000..bb8ba29 --- /dev/null +++ b/crates/manager-core/src/secrets_api.rs @@ -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, + pub apps: Arc, + pub authz: Arc, + 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, + #[serde(default)] + pub limit: Option, +} + +#[derive(Debug, serde::Serialize)] +struct SecretItem { + name: String, + updated_at: chrono::DateTime, +} + +#[derive(Debug, serde::Serialize)] +struct ListSecretsResponse { + secrets: Vec, + next_cursor: Option, +} + +async fn list_secrets( + State(s): State, + Extension(principal): Extension, + Path(app_id): Path, + Query(q): Query, +) -> Result, 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, + Extension(principal): Extension, + Path(app_id): Path, + Json(input): Json, +) -> Result { + 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, + Extension(principal): Extension, + Path((app_id, name)): Path<(AppId, String)>, +) -> Result { + 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 for SecretsApiError { + fn from(d: AuthzDenied) -> Self { + match d { + AuthzDenied::Denied => Self::Forbidden, + AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), + } + } +} + +impl From for SecretsApiError { + fn from(e: AuthzError) -> Self { + Self::AuthzRepo(e.to_string()) + } +} + +impl From 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 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() + } +} diff --git a/crates/manager-core/src/secrets_repo.rs b/crates/manager-core/src/secrets_repo.rs new file mode 100644 index 0000000..e36c855 --- /dev/null +++ b/crates/manager-core/src/secrets_repo.rs @@ -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, + pub nonce: Vec, +} + +/// 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, +} + +/// One page of names (SDK `list`). +#[derive(Debug, Clone)] +pub struct SecretsNamePage { + pub names: Vec, + pub next_cursor: Option, +} + +/// One page of name + updated_at (admin `GET`). +#[derive(Debug, Clone)] +pub struct SecretsMetaPage { + pub items: Vec, + pub next_cursor: Option, +} + +/// 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, 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; + + /// Names only — the SDK `list` surface. + async fn list_names( + &self, + app_id: AppId, + cursor: Option<&str>, + limit: u32, + ) -> Result; + + /// Name + updated_at — the admin `GET` surface. + async fn list_meta( + &self, + app_id: AppId, + cursor: Option<&str>, + limit: u32, + ) -> Result; +} + +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 { + 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, SecretsRepoError> { + let row: Option<(Vec, Vec)> = 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 { + 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 { + 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 = 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 { + 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)> = 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 = 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 }) + } +} diff --git a/crates/manager-core/src/secrets_service.rs b/crates/manager-core/src/secrets_service.rs new file mode 100644 index 0000000..46765cf --- /dev/null +++ b/crates/manager-core/src/secrets_service.rs @@ -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::() { + 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; 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 { + 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, + authz: Arc, + master_key: MasterKey, + max_value_bytes: usize, +} + +impl SecretsServiceImpl { + #[must_use] + pub fn new( + repo: Arc, + authz: Arc, + 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 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, 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 { + 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 { + 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>, + } + + #[async_trait] + impl SecretsRepo for InMemorySecretsRepo { + async fn get( + &self, + app_id: AppId, + name: &str, + ) -> Result, 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 { + 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 { + let data = self.data.lock().await; + let last = cursor.map(std::string::ToString::to_string); + let mut names: Vec = 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 { + 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, 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) -> 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()); + } +} diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 1adb9a3..baf197a 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -12,21 +12,22 @@ use picloud_executor_core::{Engine, Limits}; use picloud_manager_core::{ admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router, - files_admin_router, migrations, require_authenticated, route_admin_router, topics_router, - triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, - AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, - AppMembersRepository, AppMembersState, AppRepository, AppsState, AuthState, AuthzRepo, - DeadLetterRepo, DeadLettersState, Dispatcher, DocsServiceImpl, FilesAdminState, FilesConfig, - FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl, KvServiceImpl, OutboxEventEmitter, - OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, - PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, - PostgresAppRepository, PostgresAppSecretsRepo, PostgresDeadLetterRepo, - PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository, - PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, - PostgresRouteRepository, PostgresScriptRepository, PostgresTopicRepo, PostgresTriggerRepo, - PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState, - RouteRepository, SandboxCeiling, ScriptRepository, SubscriberTokenConfig, TopicRepo, - TopicsState, TriggerConfig, TriggerRepo, TriggersState, + files_admin_router, migrations, require_authenticated, route_admin_router, secrets_router, + topics_router, triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, + AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, + AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, AppsState, + AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, Dispatcher, DocsServiceImpl, + FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl, + KvServiceImpl, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, + PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, + PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository, + PostgresAppSecretsRepo, PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo, + PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, + PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, + PostgresTopicRepo, PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl, + RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling, + ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig, + TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, }; use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; @@ -36,9 +37,9 @@ use picloud_orchestrator_core::{ }; use picloud_shared::{ DeadLetterService, DocsService, ExecutionLogSink, FilesService, HttpService, InboxResolver, - KvService, OutboxWriter, PubsubService, RealtimeAuthority, RealtimeBroadcaster, - ScriptValidator, ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION, - WIRE_VERSION, + KvService, MasterKey, OutboxWriter, PubsubService, RealtimeAuthority, RealtimeBroadcaster, + ScriptValidator, SecretsService, ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION, + SDK_VERSION, WIRE_VERSION, }; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; @@ -94,7 +95,11 @@ fn read_session_ttl() -> Duration { /// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`, /// `/version`) stays open — it's the public ingress for user scripts. #[allow(clippy::too_many_lines)] -pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { +pub async fn build_app( + pool: PgPool, + auth: AuthDeps, + master_key: MasterKey, +) -> anyhow::Result { let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone())); let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone())); let log_sink: Arc = Arc::new(PostgresExecutionLogSink::new(pool.clone())); @@ -203,6 +208,20 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { 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 = + Arc::new(PostgresSecretsRepo::new(pool.clone())); + let secrets: Arc = Arc::new(SecretsServiceImpl::new( + secrets_repo.clone(), + authz.clone(), + master_key.clone(), + secrets_config, + )); let services = Services::new( kv, docs, @@ -212,6 +231,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { http, files, pubsub, + secrets, ); let engine = Arc::new(Engine::new(Limits::default(), services)); @@ -340,6 +360,13 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { authz: authz.clone(), broadcaster: broadcaster.clone(), }; + let secrets_state = SecretsState { + repo: secrets_repo, + apps: apps_repo.clone(), + authz: authz.clone(), + master_key, + max_value_bytes: secrets_max_value_bytes, + }; let apps_state = AppsState { apps: apps_repo, domains: domains_repo, @@ -384,6 +411,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { .merge(triggers_router(triggers_state)) .merge(files_admin_router(files_admin_state)) .merge(topics_router(topics_state)) + .merge(secrets_router(secrets_state)) .merge(dead_letters_router(dead_letters_state)) .layer(from_fn_with_state( auth_state.clone(), diff --git a/crates/picloud/src/main.rs b/crates/picloud/src/main.rs index 08a71b3..c05afd6 100644 --- a/crates/picloud/src/main.rs +++ b/crates/picloud/src/main.rs @@ -39,6 +39,11 @@ async fn run_server() -> anyhow::Result<()> { let database_url = std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL is required"))?; + // Source the process master key BEFORE doing any work — an unset or + // malformed PICLOUD_SECRET_KEY is fatal (v1.1.7). The only escape + // hatch is PICLOUD_DEV_MODE=true, which logs a prominent warning. + let master_key = picloud_shared::MasterKey::from_env()?; + let pool = init_db(&database_url).await?; migrations::run(&pool).await?; tracing::info!("migrations applied"); @@ -69,7 +74,7 @@ async fn run_server() -> anyhow::Result<()> { // so a delayed sweep can't extend session lifetimes. spawn_session_pruner(auth.sessions.clone()); - let app = build_app(pool, auth).await?; + let app = build_app(pool, auth, master_key).await?; let listener = tokio::net::TcpListener::bind(addr).await?; tracing::info!(%addr, "picloud all-in-one listening"); diff --git a/crates/picloud/tests/api.rs b/crates/picloud/tests/api.rs index 3e1fe80..cdf8aba 100644 --- a/crates/picloud/tests/api.rs +++ b/crates/picloud/tests/api.rs @@ -40,7 +40,13 @@ async fn server_with_app(pool: PgPool) -> (TestServer, String) { .await .expect("seed admin"); - let app = picloud::build_app(pool, auth).await.expect("build_app"); + let app = picloud::build_app( + pool, + auth, + picloud_shared::MasterKey::from_bytes([0x42u8; 32]), + ) + .await + .expect("build_app"); let mut server = TestServer::new(app).expect("TestServer should build"); let resp = server diff --git a/crates/picloud/tests/authz.rs b/crates/picloud/tests/authz.rs index a82b6c7..85ccc2d 100644 --- a/crates/picloud/tests/authz.rs +++ b/crates/picloud/tests/authz.rs @@ -57,9 +57,13 @@ async fn boot(pool: PgPool) -> Seeded { .await .expect("seed owner"); - let app = picloud::build_app(pool.clone(), auth) - .await - .expect("build_app"); + let app = picloud::build_app( + pool.clone(), + auth, + picloud_shared::MasterKey::from_bytes([0x42u8; 32]), + ) + .await + .expect("build_app"); let server = TestServer::new(app).expect("TestServer"); // Default app id (seeded by migration 0005). diff --git a/crates/picloud/tests/dispatcher_e2e.rs b/crates/picloud/tests/dispatcher_e2e.rs index daedf42..d48f060 100644 --- a/crates/picloud/tests/dispatcher_e2e.rs +++ b/crates/picloud/tests/dispatcher_e2e.rs @@ -67,7 +67,13 @@ async fn server_for(pool: PgPool, suffix: &str) -> (TestServer, String) { .await .expect("seed admin"); - let app = picloud::build_app(pool, auth).await.expect("build_app"); + let app = picloud::build_app( + pool, + auth, + picloud_shared::MasterKey::from_bytes([0x42u8; 32]), + ) + .await + .expect("build_app"); let mut server = TestServer::new(app).expect("TestServer"); let resp = server .post("/api/v1/admin/auth/login") diff --git a/crates/shared/src/crypto.rs b/crates/shared/src/crypto.rs index e0db2fd..8ede3dd 100644 --- a/crates/shared/src/crypto.rs +++ b/crates/shared/src/crypto.rs @@ -92,7 +92,11 @@ pub fn encrypt(plaintext: &[u8], key: &[u8; KEY_LEN]) -> EncryptResult { /// Returns [`CryptoError::InvalidNonce`] if `nonce` is the wrong length, /// or [`CryptoError::Decrypt`] if authentication fails for any reason /// (wrong key, corruption, tampering). -pub fn decrypt(ciphertext: &[u8], nonce: &[u8], key: &[u8; KEY_LEN]) -> Result, CryptoError> { +pub fn decrypt( + ciphertext: &[u8], + nonce: &[u8], + key: &[u8; KEY_LEN], +) -> Result, CryptoError> { if nonce.len() != NONCE_LEN { return Err(CryptoError::InvalidNonce(nonce.len())); } @@ -238,7 +242,7 @@ mod tests { fn test_key() -> [u8; KEY_LEN] { let mut k = [0u8; KEY_LEN]; for (i, b) in k.iter_mut().enumerate() { - *b = i as u8; + *b = u8::try_from(i).unwrap_or(0); } k } diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 486000e..6b8ee56 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -28,6 +28,7 @@ pub mod route; pub mod sandbox; pub mod script; pub mod sdk_cx; +pub mod secrets; pub mod services; pub mod subscriber_token; pub mod trigger_event; @@ -65,6 +66,10 @@ pub use route::{DispatchMode, HostKind, PathKind, Route}; pub use sandbox::ScriptSandbox; pub use script::{Script, ScriptKind}; pub use sdk_cx::SdkCallCx; +pub use secrets::{ + validate_secret_name, NoopSecretsService, SecretsError, SecretsListPage, SecretsService, + SECRET_NAME_MAX_BYTES, +}; pub use services::Services; pub use trigger_event::{ DeadLetterEventDetail, DocsEventOp, FilesEventOp, KvEventOp, TriggerEvent, diff --git a/crates/shared/src/secrets.rs b/crates/shared/src/secrets.rs new file mode 100644 index 0000000..fe23113 --- /dev/null +++ b/crates/shared/src/secrets.rs @@ -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, 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; + + /// 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; +} + +/// 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, + pub next_cursor: Option, +} + +/// 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, 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 { + Err(SecretsError::Backend("secrets is not wired in".into())) + } + + async fn list( + &self, + _cx: &SdkCallCx, + _cursor: Option<&str>, + _limit: u32, + ) -> Result { + 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(()) +} diff --git a/crates/shared/src/services.rs b/crates/shared/src/services.rs index 2f41f65..672388c 100644 --- a/crates/shared/src/services.rs +++ b/crates/shared/src/services.rs @@ -22,7 +22,8 @@ use std::sync::Arc; use crate::{ DeadLetterService, DocsService, FilesService, HttpService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopFilesService, NoopHttpService, - NoopKvService, NoopModuleSource, NoopPubsubService, PubsubService, ServiceEventEmitter, + NoopKvService, NoopModuleSource, NoopPubsubService, NoopSecretsService, PubsubService, + SecretsService, ServiceEventEmitter, }; /// SDK service bundle. See module docs for the lifecycle and the v1.1.x @@ -73,6 +74,12 @@ pub struct Services { /// publish-time outbox fan-out in the picloud binary; /// `NoopPubsubService` in tests that don't publish. pub pubsub: Arc, + + /// 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, } impl Services { @@ -90,6 +97,7 @@ impl Services { http: Arc, files: Arc, pubsub: Arc, + secrets: Arc, ) -> Self { Self { kv, @@ -100,6 +108,7 @@ impl Services { http, files, pubsub, + secrets, } } @@ -119,6 +128,7 @@ impl Services { Arc::new(NoopHttpService), Arc::new(NoopFilesService), Arc::new(NoopPubsubService), + Arc::new(NoopSecretsService), ) } } diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 91fadb3..2897a93 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -292,6 +292,11 @@ export interface UpdateTopicInput { auth_mode?: TopicAuthMode; } +export interface SecretListItem { + name: string; + updated_at: string; +} + export interface ExecutionResult { status: number; headers: Record; @@ -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(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/secrets`, { + method: 'POST', + body: JSON.stringify({ name, value }) + }), + remove: (idOrSlug: string, name: string) => + adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/secrets/${encodeURIComponent(name)}`, + { method: 'DELETE' } + ) + }, + execute: async ( id: string, body: unknown, diff --git a/dashboard/src/routes/apps/[slug]/+page.svelte b/dashboard/src/routes/apps/[slug]/+page.svelte index 709f0fa..b237473 100644 --- a/dashboard/src/routes/apps/[slug]/+page.svelte +++ b/dashboard/src/routes/apps/[slug]/+page.svelte @@ -13,7 +13,8 @@ type Script, type Trigger, type Topic, - type TopicAuthMode + type TopicAuthMode, + type SecretListItem } from '$lib/api'; import CodeEditor from '$lib/CodeEditor.svelte'; import ConfirmModal from '$lib/ConfirmModal.svelte'; @@ -27,7 +28,7 @@ const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}'; - type Tab = 'scripts' | 'domains' | 'members' | 'settings' | 'triggers' | 'topics'; + type Tab = 'scripts' | 'domains' | 'members' | 'settings' | 'triggers' | 'topics' | 'secrets'; // Common IANA timezones offered in the cron form dropdown. Not // exhaustive — the backend validates any IANA name via chrono-tz. @@ -290,6 +291,83 @@ } } + // Secrets tab (v1.1.7). The dashboard only ever sees names + + // last-modified — values never leave the server. The create form's + // value input is masked by default; revealing it requires a confirm. + let secrets = $state([]); + let createSecretName = $state(''); + let createSecretValue = $state(''); + let showSecretValue = $state(false); + let revealConfirm = $state(false); + let creatingSecret = $state(false); + let createSecretError = $state(null); + let secretToRemove = $state(null); + let removingSecret = $state(false); + // True when the name already exists — set is an overwrite. + const secretNameExists = $derived( + secrets.some((s) => s.name === createSecretName.trim()) + ); + + async function loadSecrets(idOrSlug: string) { + try { + const r = await api.secrets.list(idOrSlug); + secrets = r.secrets; + } catch { + secrets = []; + } + } + + function toggleShowSecretValue(e: Event) { + const target = e.currentTarget as HTMLInputElement; + if (target.checked) { + // Revealing a secret on screen is sensitive — gate behind a + // confirm. Revert the checkbox until the user confirms. + target.checked = false; + revealConfirm = true; + } else { + showSecretValue = false; + } + } + + function confirmRevealSecret() { + showSecretValue = true; + revealConfirm = false; + } + + async function submitCreateSecret(e: SubmitEvent) { + e.preventDefault(); + if (!app) return; + creatingSecret = true; + createSecretError = null; + try { + await api.secrets.set(app.id, createSecretName.trim(), createSecretValue); + createSecretName = ''; + createSecretValue = ''; + showSecretValue = false; + await loadSecrets(app.id); + } catch (err) { + createSecretError = + err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err); + } finally { + creatingSecret = false; + } + } + + async function confirmRemoveSecret() { + if (!app || !secretToRemove) return; + removingSecret = true; + try { + await api.secrets.remove(app.id, secretToRemove.name); + secretToRemove = null; + await loadSecrets(app.id); + } catch (err) { + createSecretError = + err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err); + } finally { + removingSecret = false; + } + } + // Members tab let eligibleUsers = $state([]); let eligibleLoadError = $state(null); @@ -334,7 +412,8 @@ loadMembers(app.id), loadEligibleUsers(), loadTriggers(app.id), - loadTopics(app.id) + loadTopics(app.id), + loadSecrets(app.id) ); } await Promise.all(loaders); @@ -607,7 +686,8 @@ (activeTab === 'settings' || activeTab === 'members' || activeTab === 'triggers' || - activeTab === 'topics') + activeTab === 'topics' || + activeTab === 'secrets') ) { activeTab = 'scripts'; } @@ -660,6 +740,11 @@ class:active={activeTab === 'topics'} onclick={() => (activeTab = 'topics')}>Topics ({topics.length}) + + + + + {#if secrets.length === 0} +

No secrets in this app yet.

+ {:else} +
    + {#each secrets as s (s.name)} +
  • +
    + {s.name} + · updated {shortDate(s.updated_at)} +
    +
    + +
    +
  • + {/each} +
+ {/if} + {:else if activeTab === 'settings' && canAdmin}

Settings

@@ -1364,6 +1519,38 @@

{/if} + + {#if revealConfirm} + (revealConfirm = false)} + > +

+ 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. +

+
+ {/if} + + {#if secretToRemove} + (secretToRemove = null)} + > +

+ Deleting {secretToRemove.name} is permanent. Any script calling + secrets::get("{secretToRemove.name}") will get () + until you set it again. +

+
+ {/if} {/if}