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

View File

@@ -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);

View File

@@ -99,6 +99,7 @@ fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
Arc::new(NoopHttpService),
Arc::new(picloud_shared::NoopFilesService),
Arc::new(picloud_shared::NoopPubsubService),
Arc::new(picloud_shared::NoopSecretsService),
)
}

View File

@@ -230,6 +230,7 @@ fn make_engine() -> Arc<Engine> {
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))
}

View File

@@ -167,6 +167,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(NoopHttpService),
Arc::new(InMemoryFiles::default()),
Arc::new(picloud_shared::NoopPubsubService),
Arc::new(picloud_shared::NoopSecretsService),
);
Arc::new(Engine::new(Limits::default(), services))
}

View File

@@ -90,6 +90,7 @@ fn engine_with(http: Arc<dyn HttpService>) -> Arc<Engine> {
http,
Arc::new(picloud_shared::NoopFilesService),
Arc::new(picloud_shared::NoopPubsubService),
Arc::new(picloud_shared::NoopSecretsService),
);
Arc::new(Engine::new(Limits::default(), services))
}

View File

@@ -109,6 +109,7 @@ fn make_engine() -> Arc<Engine> {
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))
}

View File

@@ -47,6 +47,7 @@ fn make_engine(svc: Arc<RecordingPubsub>) -> Arc<Engine> {
Arc::new(NoopHttpService),
Arc::new(NoopFilesService),
svc,
Arc::new(picloud_shared::NoopSecretsService),
);
Arc::new(Engine::new(Limits::default(), services))
}

View File

@@ -0,0 +1,212 @@
//! `secrets::` SDK bridge integration tests — runs a real Rhai engine
//! against an in-memory `SecretsService` impl. Mirrors `sdk_kv.rs`: the
//! engine runs under `spawn_blocking` so the bridge's `block_on` has a
//! reachable runtime.
//!
//! This exercises the Rhai⇄JSON plumbing + the static `secrets` module
//! (set/get/delete/list, the missing→() contract, and the
//! String/Map/Array type round-trip). Encryption + authz + the
//! cross-app boundary are unit-tested at the service layer in
//! `manager-core::secrets_service`.
use std::collections::BTreeMap;
use std::sync::Arc;
use async_trait::async_trait;
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
use picloud_shared::{
AppId, ExecutionId, NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService,
NoopKvService, NoopModuleSource, RequestId, ScriptId, ScriptSandbox, SdkCallCx, SecretsError,
SecretsListPage, SecretsService, Services,
};
use serde_json::{json, Value};
use tokio::sync::Mutex;
/// In-memory secrets store keyed by `(app_id, name)`. Stores the JSON
/// value directly — the bridge test only cares about the Rhai plumbing,
/// not the at-rest encryption (which the service layer owns).
#[derive(Default)]
struct InMemorySecrets {
data: Mutex<BTreeMap<(AppId, String), Value>>,
}
#[async_trait]
impl SecretsService for InMemorySecrets {
async fn get(&self, cx: &SdkCallCx, name: &str) -> Result<Option<Value>, SecretsError> {
picloud_shared::validate_secret_name(name)?;
Ok(self
.data
.lock()
.await
.get(&(cx.app_id, name.to_string()))
.cloned())
}
async fn set(&self, cx: &SdkCallCx, name: &str, value: Value) -> Result<(), SecretsError> {
picloud_shared::validate_secret_name(name)?;
self.data
.lock()
.await
.insert((cx.app_id, name.to_string()), value);
Ok(())
}
async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError> {
picloud_shared::validate_secret_name(name)?;
Ok(self
.data
.lock()
.await
.remove(&(cx.app_id, name.to_string()))
.is_some())
}
async fn list(
&self,
cx: &SdkCallCx,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsListPage, SecretsError> {
let data = self.data.lock().await;
let mut names: Vec<String> = data
.iter()
.filter(|((a, _), _)| *a == cx.app_id)
.map(|((_, n), _)| n.clone())
.filter(|n| cursor.is_none_or(|c| n.as_str() > c))
.collect();
names.sort();
let take = if limit == 0 {
usize::MAX
} else {
limit as usize
};
let next_cursor = if names.len() > take {
names.truncate(take);
names.last().cloned()
} else {
None
};
Ok(SecretsListPage { names, next_cursor })
}
}
fn make_engine() -> Arc<Engine> {
let services = Services::new(
Arc::new(NoopKvService),
Arc::new(NoopDocsService),
Arc::new(NoopDeadLetterService),
Arc::new(NoopEventEmitter),
Arc::new(NoopModuleSource),
Arc::new(NoopHttpService),
Arc::new(picloud_shared::NoopFilesService),
Arc::new(picloud_shared::NoopPubsubService),
Arc::new(InMemorySecrets::default()),
);
Arc::new(Engine::new(Limits::default(), services))
}
fn baseline_request(app_id: AppId) -> ExecRequest {
let execution_id = ExecutionId::new();
ExecRequest {
execution_id,
request_id: RequestId::new(),
script_id: ScriptId::new(),
script_name: "secrets-test".into(),
invocation_type: InvocationType::Http,
path: "/secrets-test".into(),
headers: BTreeMap::new(),
body: Value::Null,
params: BTreeMap::new(),
query: BTreeMap::new(),
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
app_id,
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,
is_dead_letter_handler: false,
event: None,
}
}
async fn run_script(engine: Arc<Engine>, src: &str, req: ExecRequest) -> Value {
let src = src.to_string();
tokio::task::spawn_blocking(move || engine.execute(&src, req))
.await
.expect("spawn_blocking should not panic")
.expect("script execution should succeed")
.body
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn set_then_get_string_round_trips() {
let engine = make_engine();
let src = r#"
secrets::set("stripe_key", "sk_live_xxx");
secrets::get("stripe_key")
"#;
let body = run_script(engine, src, baseline_request(AppId::new())).await;
// A String comes back a String, not a JSON-quoted "\"sk_live_xxx\"".
assert_eq!(body, json!("sk_live_xxx"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn set_then_get_map_round_trips() {
let engine = make_engine();
let src = r#"
secrets::set("oauth", #{ client_id: "abc", client_secret: "xyz" });
secrets::get("oauth")
"#;
let body = run_script(engine, src, baseline_request(AppId::new())).await;
assert_eq!(body, json!({ "client_id": "abc", "client_secret": "xyz" }));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_missing_returns_unit() {
let engine = make_engine();
let src = r#"
let v = secrets::get("nope");
#{ is_unit: type_of(v) == "()" }
"#;
let body = run_script(engine, src, baseline_request(AppId::new())).await;
assert_eq!(body, json!({ "is_unit": true }));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn delete_returns_was_present() {
let engine = make_engine();
let src = r#"
secrets::set("k", "v");
let first = secrets::delete("k");
let second = secrets::delete("k");
#{ first: first, second: second }
"#;
let body = run_script(engine, src, baseline_request(AppId::new())).await;
assert_eq!(body, json!({ "first": true, "second": false }));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_returns_names_and_cursor() {
let engine = make_engine();
let src = r#"
secrets::set("a", 1);
secrets::set("b", 2);
secrets::set("c", 3);
let page = secrets::list(#{ cursor: (), limit: 2 });
page
"#;
let body = run_script(engine, src, baseline_request(AppId::new())).await;
assert_eq!(body["names"], json!(["a", "b"]));
assert_eq!(body["next_cursor"], json!("b"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn empty_name_throws() {
let engine = make_engine();
let src = r#" secrets::set("", "v"); #{ ok: true } "#;
let app = AppId::new();
let out = tokio::task::spawn_blocking(move || engine.execute(src, baseline_request(app)))
.await
.expect("spawn_blocking");
assert!(out.is_err(), "empty secret name must throw");
}

View File

@@ -94,6 +94,7 @@ fn make_engine() -> Arc<Engine> {
Arc::new(NoopHttpService),
Arc::new(NoopFilesService),
Arc::new(FakeMintPubsub),
Arc::new(picloud_shared::NoopSecretsService),
);
Arc::new(Engine::new(Limits::default(), services))
}

View File

@@ -0,0 +1,24 @@
-- v1.1.7: encrypted per-app secrets.
--
-- Operational config (API keys, OAuth tokens, webhook signing keys)
-- encrypted at rest with the process master key (AES-256-GCM). Both the
-- ciphertext (16-byte GCM auth tag appended) and the 12-byte nonce are
-- stored; the master key itself never lives in the database. See
-- `picloud_shared::crypto` + `manager-core::secrets_service`.
--
-- This is the user-facing `secrets::*` store. It is intentionally
-- separate from `app_secrets` (the one-row-per-app realtime signing
-- key, 0022): different cardinality (many named rows per app), and the
-- realtime key is encrypted in place by migration 0025.
CREATE TABLE secrets (
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
name TEXT NOT NULL,
encrypted_value BYTEA NOT NULL, -- ciphertext incl. 16-byte GCM auth tag
nonce BYTEA NOT NULL, -- 12 bytes
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (app_id, name)
);
CREATE INDEX idx_secrets_app ON secrets (app_id);

View File

@@ -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!(

View File

@@ -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};

View File

@@ -0,0 +1,232 @@
//! `/api/v1/admin/apps/{id}/secrets*` — secrets admin endpoints
//! (v1.1.7).
//!
//! * `GET /apps/{id}/secrets` — list names + updated_at
//! (NEVER values).
//! * `POST /apps/{id}/secrets` — set/overwrite a secret.
//! * `DELETE /apps/{id}/secrets/{name}` — delete a secret.
//!
//! Set/delete are gated by `AppSecretsWrite` (→ `script:write`); list by
//! `AppSecretsRead` (→ `script:read`). The list surface deliberately
//! returns only names + timestamps — the dashboard never receives
//! plaintext. Values are encrypted with the process master key before
//! they touch the database (same envelope as the script `secrets::set`).
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use picloud_shared::{validate_secret_name, AppId, MasterKey, Principal, SecretsError};
use serde::Deserialize;
use serde_json::json;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::secrets_repo::{SecretsRepo, SecretsRepoError};
use crate::secrets_service::seal;
#[derive(Clone)]
pub struct SecretsState {
pub repo: Arc<dyn SecretsRepo>,
pub apps: Arc<dyn AppRepository>,
pub authz: Arc<dyn AuthzRepo>,
pub master_key: MasterKey,
pub max_value_bytes: usize,
}
pub fn secrets_router(state: SecretsState) -> Router {
Router::new()
.route("/apps/{app_id}/secrets", get(list_secrets).post(set_secret))
.route(
"/apps/{app_id}/secrets/{name}",
axum::routing::delete(delete_secret),
)
.with_state(state)
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
#[serde(default)]
pub cursor: Option<String>,
#[serde(default)]
pub limit: Option<u32>,
}
#[derive(Debug, serde::Serialize)]
struct SecretItem {
name: String,
updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, serde::Serialize)]
struct ListSecretsResponse {
secrets: Vec<SecretItem>,
next_cursor: Option<String>,
}
async fn list_secrets(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>,
Query(q): Query<ListQuery>,
) -> Result<Json<ListSecretsResponse>, SecretsApiError> {
ensure_app_exists(&*s.apps, app_id).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppSecretsRead(app_id),
)
.await?;
let page = s
.repo
.list_meta(app_id, q.cursor.as_deref(), q.limit.unwrap_or(0))
.await?;
Ok(Json(ListSecretsResponse {
secrets: page
.items
.into_iter()
.map(|m| SecretItem {
name: m.name,
updated_at: m.updated_at,
})
.collect(),
next_cursor: page.next_cursor,
}))
}
#[derive(Debug, Deserialize)]
pub struct SetSecretRequest {
pub name: String,
/// Any JSON value — the dashboard sends a single-line string, but
/// maps/arrays/numbers round-trip too (matching `secrets::set`).
pub value: serde_json::Value,
}
async fn set_secret(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>,
Json(input): Json<SetSecretRequest>,
) -> Result<StatusCode, SecretsApiError> {
ensure_app_exists(&*s.apps, app_id).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppSecretsWrite(app_id),
)
.await?;
validate_secret_name(&input.name)?;
let (ciphertext, nonce) = seal(&s.master_key, &input.value, s.max_value_bytes)?;
s.repo.set(app_id, &input.name, &ciphertext, &nonce).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn delete_secret(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path((app_id, name)): Path<(AppId, String)>,
) -> Result<StatusCode, SecretsApiError> {
ensure_app_exists(&*s.apps, app_id).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppSecretsWrite(app_id),
)
.await?;
if !s.repo.delete(app_id, &name).await? {
return Err(SecretsApiError::NotFound);
}
Ok(StatusCode::NO_CONTENT)
}
async fn ensure_app_exists(apps: &dyn AppRepository, app_id: AppId) -> Result<(), SecretsApiError> {
apps.get_by_id(app_id)
.await
.map_err(|e| SecretsApiError::Backend(e.to_string()))?
.ok_or(SecretsApiError::AppNotFound)?;
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum SecretsApiError {
#[error("app not found")]
AppNotFound,
#[error("secret not found")]
NotFound,
#[error("invalid request: {0}")]
Invalid(String),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("secrets backend: {0}")]
Backend(String),
}
impl From<AuthzDenied> for SecretsApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl From<AuthzError> for SecretsApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl From<SecretsRepoError> for SecretsApiError {
fn from(e: SecretsRepoError) -> Self {
match e {
SecretsRepoError::InvalidCursor => Self::Invalid("invalid pagination cursor".into()),
SecretsRepoError::Db(e) => Self::Backend(e.to_string()),
}
}
}
impl From<SecretsError> for SecretsApiError {
fn from(e: SecretsError) -> Self {
match e {
SecretsError::InvalidName(m) => Self::Invalid(m),
SecretsError::TooLarge { .. } => Self::Invalid(e.to_string()),
SecretsError::Forbidden => Self::Forbidden,
other => Self::Backend(other.to_string()),
}
}
}
impl IntoResponse for SecretsApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound | Self::NotFound => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::Invalid(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "secrets admin authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Backend(e) => {
tracing::error!(error = %e, "secrets admin backend error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -0,0 +1,246 @@
//! Low-level Postgres CRUD over `secrets`. Storage-only: it moves
//! opaque ciphertext + nonce blobs in and out. Encryption, JSON
//! encoding, authorization, name validation, and the value-size cap all
//! live one layer up in `SecretsServiceImpl` / `secrets_api`.
use async_trait::async_trait;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chrono::{DateTime, Utc};
use picloud_shared::AppId;
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
pub enum SecretsRepoError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("invalid pagination cursor")]
InvalidCursor,
}
/// An encrypted secret as it lives on disk: ciphertext (auth tag
/// appended) plus the nonce it was sealed with.
#[derive(Debug, Clone)]
pub struct StoredSecret {
pub encrypted_value: Vec<u8>,
pub nonce: Vec<u8>,
}
/// Admin-surface metadata for one secret. Values are never returned —
/// only the name and the last-modified timestamp.
#[derive(Debug, Clone)]
pub struct SecretMeta {
pub name: String,
pub updated_at: DateTime<Utc>,
}
/// One page of names (SDK `list`).
#[derive(Debug, Clone)]
pub struct SecretsNamePage {
pub names: Vec<String>,
pub next_cursor: Option<String>,
}
/// One page of name + updated_at (admin `GET`).
#[derive(Debug, Clone)]
pub struct SecretsMetaPage {
pub items: Vec<SecretMeta>,
pub next_cursor: Option<String>,
}
/// Repo surface. Exposed as a trait so the service unit tests can
/// substitute an in-memory backing without Postgres.
#[async_trait]
pub trait SecretsRepo: Send + Sync {
async fn get(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<StoredSecret>, SecretsRepoError>;
/// Upsert (overwrite if present).
async fn set(
&self,
app_id: AppId,
name: &str,
encrypted_value: &[u8],
nonce: &[u8],
) -> Result<(), SecretsRepoError>;
/// Delete; returns whether a row was present.
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError>;
/// Names only — the SDK `list` surface.
async fn list_names(
&self,
app_id: AppId,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsNamePage, SecretsRepoError>;
/// Name + updated_at — the admin `GET` surface.
async fn list_meta(
&self,
app_id: AppId,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsMetaPage, SecretsRepoError>;
}
pub struct PostgresSecretsRepo {
pool: PgPool,
}
impl PostgresSecretsRepo {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
const SECRETS_LIST_MAX_LIMIT: u32 = 1_000;
const SECRETS_LIST_DEFAULT_LIMIT: u32 = 100;
fn clamp_limit(limit: u32) -> u32 {
if limit == 0 {
SECRETS_LIST_DEFAULT_LIMIT
} else {
limit.min(SECRETS_LIST_MAX_LIMIT)
}
}
/// Opaque keyset cursor: base64url of the last `name` returned.
pub(crate) fn encode_cursor(last_name: &str) -> String {
URL_SAFE_NO_PAD.encode(last_name.as_bytes())
}
pub(crate) fn decode_cursor(cursor: &str) -> Result<String, SecretsRepoError> {
let bytes = URL_SAFE_NO_PAD
.decode(cursor)
.map_err(|_| SecretsRepoError::InvalidCursor)?;
String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)
}
#[async_trait]
impl SecretsRepo for PostgresSecretsRepo {
async fn get(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<StoredSecret>, SecretsRepoError> {
let row: Option<(Vec<u8>, Vec<u8>)> = sqlx::query_as(
"SELECT encrypted_value, nonce FROM secrets WHERE app_id = $1 AND name = $2",
)
.bind(app_id.into_inner())
.bind(name)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|(encrypted_value, nonce)| StoredSecret {
encrypted_value,
nonce,
}))
}
async fn set(
&self,
app_id: AppId,
name: &str,
encrypted_value: &[u8],
nonce: &[u8],
) -> Result<(), SecretsRepoError> {
sqlx::query(
"INSERT INTO secrets (app_id, name, encrypted_value, nonce) \
VALUES ($1, $2, $3, $4) \
ON CONFLICT (app_id, name) DO UPDATE \
SET encrypted_value = EXCLUDED.encrypted_value, \
nonce = EXCLUDED.nonce, \
updated_at = NOW()",
)
.bind(app_id.into_inner())
.bind(name)
.bind(encrypted_value)
.bind(nonce)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
let res = sqlx::query("DELETE FROM secrets WHERE app_id = $1 AND name = $2")
.bind(app_id.into_inner())
.bind(name)
.execute(&self.pool)
.await?;
Ok(res.rows_affected() > 0)
}
async fn list_names(
&self,
app_id: AppId,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsNamePage, SecretsRepoError> {
let limit = clamp_limit(limit);
let last_name = match cursor {
Some(c) => Some(decode_cursor(c)?),
None => None,
};
let take = i64::from(limit) + 1;
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT name FROM secrets \
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
ORDER BY name ASC LIMIT $3",
)
.bind(app_id.into_inner())
.bind(last_name.as_deref())
.bind(take)
.fetch_all(&self.pool)
.await?;
let mut names: Vec<String> = rows.into_iter().map(|(n,)| n).collect();
let next_cursor = if names.len() > limit as usize {
names.truncate(limit as usize);
names.last().map(|n| encode_cursor(n))
} else {
None
};
Ok(SecretsNamePage { names, next_cursor })
}
async fn list_meta(
&self,
app_id: AppId,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsMetaPage, SecretsRepoError> {
let limit = clamp_limit(limit);
let last_name = match cursor {
Some(c) => Some(decode_cursor(c)?),
None => None,
};
let take = i64::from(limit) + 1;
let rows: Vec<(String, DateTime<Utc>)> = sqlx::query_as(
"SELECT name, updated_at FROM secrets \
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
ORDER BY name ASC LIMIT $3",
)
.bind(app_id.into_inner())
.bind(last_name.as_deref())
.bind(take)
.fetch_all(&self.pool)
.await?;
let mut items: Vec<SecretMeta> = rows
.into_iter()
.map(|(name, updated_at)| SecretMeta { name, updated_at })
.collect();
let next_cursor = if items.len() > limit as usize {
items.truncate(limit as usize);
items.last().map(|m| encode_cursor(&m.name))
} else {
None
};
Ok(SecretsMetaPage { items, next_cursor })
}
}

View File

@@ -0,0 +1,574 @@
//! `SecretsServiceImpl` — wires the `SecretsRepo` underneath the
//! `picloud_shared::SecretsService` trait that scripts see via the Rhai
//! bridge.
//!
//! Layers added here (vs the raw repo):
//!
//! 1. Name validation (non-empty, ≤255 bytes) at the SDK boundary.
//! 2. **Script-as-gate authz**: when `cx.principal.is_some()` we run
//! `authz::require(...)`; when it's `None` (public unauthenticated
//! HTTP) we skip the check. Cross-app isolation is unaffected — every
//! query is keyed by `cx.app_id`, never an argument.
//! 3. **JSON ⇄ ciphertext**: `set` serializes the value to JSON bytes,
//! enforces the per-secret size cap, and AES-256-GCM-seals it; `get`
//! decrypts and deserializes back to the same JSON shape (a String
//! round-trips to a String, not a JSON-quoted `"\"…\""`).
//!
//! Deliberately **no `ServiceEvent` emission** — secret writes do not
//! fire triggers (footgun avoidance; see `docs/sdk-shape.md` + the
//! v1.1.7 brief §2).
use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{
crypto, validate_secret_name, MasterKey, SdkCallCx, SecretsError, SecretsListPage,
SecretsService,
};
use crate::authz::{self, AuthzRepo, Capability};
use crate::secrets_repo::{SecretsRepo, SecretsRepoError, StoredSecret};
/// Default per-secret plaintext cap (64 KB). Override with
/// `PICLOUD_SECRET_MAX_VALUE_BYTES`.
pub const DEFAULT_SECRET_MAX_VALUE_BYTES: usize = 64 * 1024;
/// Process config for the secrets service.
#[derive(Debug, Clone, Copy)]
pub struct SecretsConfig {
/// Maximum size of the JSON-encoded plaintext, in bytes.
pub max_value_bytes: usize,
}
impl SecretsConfig {
#[must_use]
pub const fn conservative() -> Self {
Self {
max_value_bytes: DEFAULT_SECRET_MAX_VALUE_BYTES,
}
}
/// Read `PICLOUD_SECRET_MAX_VALUE_BYTES`; invalid values are ignored
/// with a warning (keeps the conservative default).
#[must_use]
pub fn from_env() -> Self {
let mut c = Self::conservative();
if let Ok(v) = std::env::var("PICLOUD_SECRET_MAX_VALUE_BYTES") {
match v.trim().parse::<usize>() {
Ok(n) if n > 0 => c.max_value_bytes = n,
_ => tracing::warn!(
value = %v,
"ignoring invalid PICLOUD_SECRET_MAX_VALUE_BYTES (want a positive integer)"
),
}
}
c
}
}
impl Default for SecretsConfig {
fn default() -> Self {
Self::conservative()
}
}
/// Serialize + size-check + encrypt a value into `(ciphertext, nonce)`.
///
/// # Errors
///
/// [`SecretsError::TooLarge`] when the encoded plaintext exceeds
/// `max_value_bytes`; [`SecretsError::Backend`] on a serialization
/// failure (should not happen for a `serde_json::Value`).
pub fn seal(
master_key: &MasterKey,
value: &serde_json::Value,
max_value_bytes: usize,
) -> Result<(Vec<u8>, [u8; crypto::NONCE_LEN]), SecretsError> {
let plaintext = serde_json::to_vec(value)
.map_err(|e| SecretsError::Backend(format!("encode secret value: {e}")))?;
if plaintext.len() > max_value_bytes {
return Err(SecretsError::TooLarge {
limit: max_value_bytes,
actual: plaintext.len(),
});
}
let enc = crypto::encrypt(&plaintext, master_key.as_bytes());
Ok((enc.ciphertext, enc.nonce))
}
/// Decrypt + deserialize a stored secret back to its JSON value.
///
/// # Errors
///
/// [`SecretsError::Corrupted`] when decryption or JSON decoding fails.
pub fn open(
master_key: &MasterKey,
stored: &StoredSecret,
) -> Result<serde_json::Value, SecretsError> {
let plaintext = crypto::decrypt(
&stored.encrypted_value,
&stored.nonce,
master_key.as_bytes(),
)
.map_err(|_| SecretsError::Corrupted)?;
serde_json::from_slice(&plaintext).map_err(|_| SecretsError::Corrupted)
}
pub struct SecretsServiceImpl {
repo: Arc<dyn SecretsRepo>,
authz: Arc<dyn AuthzRepo>,
master_key: MasterKey,
max_value_bytes: usize,
}
impl SecretsServiceImpl {
#[must_use]
pub fn new(
repo: Arc<dyn SecretsRepo>,
authz: Arc<dyn AuthzRepo>,
master_key: MasterKey,
config: SecretsConfig,
) -> Self {
Self {
repo,
authz,
master_key,
max_value_bytes: config.max_value_bytes,
}
}
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), SecretsError> {
if let Some(ref principal) = cx.principal {
authz::require(
&*self.authz,
principal,
Capability::AppSecretsRead(cx.app_id),
)
.await
.map_err(|_| SecretsError::Forbidden)?;
}
Ok(())
}
async fn check_write(&self, cx: &SdkCallCx) -> Result<(), SecretsError> {
if let Some(ref principal) = cx.principal {
authz::require(
&*self.authz,
principal,
Capability::AppSecretsWrite(cx.app_id),
)
.await
.map_err(|_| SecretsError::Forbidden)?;
}
Ok(())
}
}
impl From<SecretsRepoError> for SecretsError {
fn from(e: SecretsRepoError) -> Self {
Self::Backend(e.to_string())
}
}
#[async_trait]
impl SecretsService for SecretsServiceImpl {
async fn get(
&self,
cx: &SdkCallCx,
name: &str,
) -> Result<Option<serde_json::Value>, SecretsError> {
validate_secret_name(name)?;
self.check_read(cx).await?;
let Some(stored) = self.repo.get(cx.app_id, name).await? else {
return Ok(None);
};
match open(&self.master_key, &stored) {
Ok(value) => Ok(Some(value)),
Err(e) => {
// A decrypt failure is operationally significant — surface
// the affected (app_id, name) so an operator can find the
// bad row, but never log the ciphertext or key material.
tracing::error!(
app_id = %cx.app_id,
secret = %name,
"secret could not be decrypted (corrupted row or master-key mismatch)"
);
Err(e)
}
}
}
async fn set(
&self,
cx: &SdkCallCx,
name: &str,
value: serde_json::Value,
) -> Result<(), SecretsError> {
validate_secret_name(name)?;
self.check_write(cx).await?;
let (ciphertext, nonce) = seal(&self.master_key, &value, self.max_value_bytes)?;
self.repo.set(cx.app_id, name, &ciphertext, &nonce).await?;
Ok(())
}
async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError> {
validate_secret_name(name)?;
self.check_write(cx).await?;
Ok(self.repo.delete(cx.app_id, name).await?)
}
async fn list(
&self,
cx: &SdkCallCx,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsListPage, SecretsError> {
self.check_read(cx).await?;
let page = self.repo.list_names(cx.app_id, cursor, limit).await?;
Ok(SecretsListPage {
names: page.names,
next_cursor: page.next_cursor,
})
}
}
// ----------------------------------------------------------------------------
// Tests — in-memory SecretsRepo so unit tests don't need Postgres.
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::authz::{AuthzError, AuthzRepo};
use crate::secrets_repo::{SecretsMetaPage, SecretsNamePage};
use async_trait::async_trait;
use picloud_shared::{
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
UserId,
};
use std::collections::BTreeMap;
use tokio::sync::Mutex;
#[derive(Default)]
struct InMemorySecretsRepo {
data: Mutex<BTreeMap<(AppId, String), StoredSecret>>,
}
#[async_trait]
impl SecretsRepo for InMemorySecretsRepo {
async fn get(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<StoredSecret>, SecretsRepoError> {
Ok(self
.data
.lock()
.await
.get(&(app_id, name.to_string()))
.cloned())
}
async fn set(
&self,
app_id: AppId,
name: &str,
encrypted_value: &[u8],
nonce: &[u8],
) -> Result<(), SecretsRepoError> {
self.data.lock().await.insert(
(app_id, name.to_string()),
StoredSecret {
encrypted_value: encrypted_value.to_vec(),
nonce: nonce.to_vec(),
},
);
Ok(())
}
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
Ok(self
.data
.lock()
.await
.remove(&(app_id, name.to_string()))
.is_some())
}
async fn list_names(
&self,
app_id: AppId,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsNamePage, SecretsRepoError> {
let data = self.data.lock().await;
let last = cursor.map(std::string::ToString::to_string);
let mut names: Vec<String> = data
.iter()
.filter(|((a, _), _)| *a == app_id)
.map(|((_, n), _)| n.clone())
.filter(|n| last.as_ref().is_none_or(|l| n > l))
.collect();
names.sort();
let take = (limit as usize).max(1);
let next_cursor = if names.len() > take {
names.truncate(take);
names.last().cloned()
} else {
None
};
Ok(SecretsNamePage { names, next_cursor })
}
async fn list_meta(
&self,
_app_id: AppId,
_cursor: Option<&str>,
_limit: u32,
) -> Result<SecretsMetaPage, SecretsRepoError> {
unimplemented!("admin-only; not exercised in service tests")
}
}
#[derive(Default)]
struct DenyingAuthzRepo;
#[async_trait]
impl AuthzRepo for DenyingAuthzRepo {
async fn membership(
&self,
_user_id: UserId,
_app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(None)
}
}
fn key() -> MasterKey {
MasterKey::from_bytes([0x5au8; 32])
}
fn svc() -> SecretsServiceImpl {
SecretsServiceImpl::new(
Arc::new(InMemorySecretsRepo::default()),
Arc::new(DenyingAuthzRepo),
key(),
SecretsConfig::conservative(),
)
}
fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
SdkCallCx {
app_id,
script_id: ScriptId::new(),
principal,
execution_id: ExecutionId::new(),
request_id: RequestId::new(),
trigger_depth: 0,
root_execution_id: ExecutionId::new(),
is_dead_letter_handler: false,
event: None,
}
}
fn anon_cx(app_id: AppId) -> SdkCallCx {
cx_with(app_id, None)
}
fn member_no_role_cx(app_id: AppId) -> SdkCallCx {
cx_with(
app_id,
Some(Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Member,
scopes: None,
app_binding: None,
}),
)
}
fn owner_cx(app_id: AppId) -> SdkCallCx {
cx_with(
app_id,
Some(Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: None,
app_binding: None,
}),
)
}
#[tokio::test]
async fn set_get_delete_round_trip() {
let s = svc();
let cx = anon_cx(AppId::new());
s.set(&cx, "stripe_key", serde_json::json!("sk_live_xxx"))
.await
.unwrap();
assert_eq!(
s.get(&cx, "stripe_key").await.unwrap(),
Some(serde_json::json!("sk_live_xxx"))
);
assert!(s.delete(&cx, "stripe_key").await.unwrap());
assert_eq!(s.get(&cx, "stripe_key").await.unwrap(), None);
// Idempotent delete.
assert!(!s.delete(&cx, "stripe_key").await.unwrap());
}
#[tokio::test]
async fn get_missing_returns_none() {
let s = svc();
let cx = anon_cx(AppId::new());
assert_eq!(s.get(&cx, "nope").await.unwrap(), None);
}
#[tokio::test]
async fn empty_name_rejected() {
let s = svc();
let cx = anon_cx(AppId::new());
let err = s.set(&cx, "", serde_json::json!("x")).await.unwrap_err();
assert!(matches!(err, SecretsError::InvalidName(_)));
let err = s.get(&cx, "").await.unwrap_err();
assert!(matches!(err, SecretsError::InvalidName(_)));
}
#[tokio::test]
async fn name_length_capped() {
let s = svc();
let cx = anon_cx(AppId::new());
let long = "a".repeat(256);
let err = s.set(&cx, &long, serde_json::json!(1)).await.unwrap_err();
assert!(matches!(err, SecretsError::InvalidName(_)));
// Exactly 255 is allowed.
let ok = "b".repeat(255);
s.set(&cx, &ok, serde_json::json!(1)).await.unwrap();
}
#[tokio::test]
async fn value_over_cap_rejected() {
let s = SecretsServiceImpl::new(
Arc::new(InMemorySecretsRepo::default()),
Arc::new(DenyingAuthzRepo),
key(),
SecretsConfig {
max_value_bytes: 16,
},
);
let cx = anon_cx(AppId::new());
let big = serde_json::json!("x".repeat(64));
let err = s.set(&cx, "k", big).await.unwrap_err();
assert!(matches!(err, SecretsError::TooLarge { limit: 16, .. }));
}
#[tokio::test]
async fn cross_app_isolation() {
let s = svc();
let a = AppId::new();
let b = AppId::new();
s.set(&anon_cx(a), "shared", serde_json::json!("from-a"))
.await
.unwrap();
s.set(&anon_cx(b), "shared", serde_json::json!("from-b"))
.await
.unwrap();
assert_eq!(
s.get(&anon_cx(a), "shared").await.unwrap(),
Some(serde_json::json!("from-a"))
);
assert_eq!(
s.get(&anon_cx(b), "shared").await.unwrap(),
Some(serde_json::json!("from-b"))
);
}
#[tokio::test]
async fn anonymous_skips_authz() {
let s = svc();
// DenyingAuthzRepo would deny an authed principal; anon skips it.
s.set(&anon_cx(AppId::new()), "k", serde_json::json!(1))
.await
.unwrap();
}
#[tokio::test]
async fn authed_member_without_role_forbidden() {
let s = svc();
let err = s
.set(&member_no_role_cx(AppId::new()), "k", serde_json::json!(1))
.await
.unwrap_err();
assert!(matches!(err, SecretsError::Forbidden));
}
#[tokio::test]
async fn owner_can_write() {
let s = svc();
s.set(&owner_cx(AppId::new()), "k", serde_json::json!(1))
.await
.unwrap();
}
/// Type round-trip: a String comes back a String, a Map a Map, an
/// Array an Array — the JSON encoding is transparent.
#[tokio::test]
async fn type_round_trip_preserves_shape() {
let s = svc();
let cx = anon_cx(AppId::new());
s.set(&cx, "str", serde_json::json!("sk_live_xxx"))
.await
.unwrap();
assert_eq!(
s.get(&cx, "str").await.unwrap(),
Some(serde_json::json!("sk_live_xxx"))
);
let map = serde_json::json!({ "client_id": "abc", "client_secret": "xyz" });
s.set(&cx, "oauth", map.clone()).await.unwrap();
assert_eq!(s.get(&cx, "oauth").await.unwrap(), Some(map));
let arr = serde_json::json!([1, 2, 3]);
s.set(&cx, "arr", arr.clone()).await.unwrap();
assert_eq!(s.get(&cx, "arr").await.unwrap(), Some(arr));
}
#[tokio::test]
async fn corrupted_ciphertext_surfaces_error() {
let repo = Arc::new(InMemorySecretsRepo::default());
let s = SecretsServiceImpl::new(
repo.clone(),
Arc::new(DenyingAuthzRepo),
key(),
SecretsConfig::conservative(),
);
let app = AppId::new();
s.set(&anon_cx(app), "k", serde_json::json!("v"))
.await
.unwrap();
// Corrupt the stored ciphertext directly.
repo.data
.lock()
.await
.get_mut(&(app, "k".to_string()))
.unwrap()
.encrypted_value[0] ^= 0xff;
let err = s.get(&anon_cx(app), "k").await.unwrap_err();
assert!(matches!(err, SecretsError::Corrupted));
}
#[tokio::test]
async fn list_returns_names_paginated() {
let s = svc();
let cx = anon_cx(AppId::new());
for i in 0..5 {
s.set(&cx, &format!("k{i:02}"), serde_json::json!(i))
.await
.unwrap();
}
let p1 = s.list(&cx, None, 2).await.unwrap();
assert_eq!(p1.names, vec!["k00".to_string(), "k01".to_string()]);
assert!(p1.next_cursor.is_some());
let p2 = s.list(&cx, p1.next_cursor.as_deref(), 10).await.unwrap();
assert_eq!(
p2.names,
vec!["k02".to_string(), "k03".to_string(), "k04".to_string()]
);
assert!(p2.next_cursor.is_none());
}
}

View File

@@ -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<Router> {
pub async fn build_app(
pool: PgPool,
auth: AuthDeps,
master_key: MasterKey,
) -> anyhow::Result<Router> {
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
@@ -203,6 +208,20 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
SubscriberTokenConfig::from_env(),
),
);
// v1.1.7 encrypted per-app secrets. Values are AES-256-GCM-sealed
// with the process master key before they touch Postgres; the repo
// only ever sees ciphertext + nonce. The admin surface reuses the
// same repo + master key (see `secrets_state` below).
let secrets_config = SecretsConfig::from_env();
let secrets_max_value_bytes = secrets_config.max_value_bytes;
let secrets_repo: Arc<dyn picloud_manager_core::SecretsRepo> =
Arc::new(PostgresSecretsRepo::new(pool.clone()));
let secrets: Arc<dyn SecretsService> = Arc::new(SecretsServiceImpl::new(
secrets_repo.clone(),
authz.clone(),
master_key.clone(),
secrets_config,
));
let services = Services::new(
kv,
docs,
@@ -212,6 +231,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
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<Router> {
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<Router> {
.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(),

View File

@@ -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");

View File

@@ -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

View File

@@ -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).

View File

@@ -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")

View File

@@ -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<Vec<u8>, CryptoError> {
pub fn decrypt(
ciphertext: &[u8],
nonce: &[u8],
key: &[u8; KEY_LEN],
) -> Result<Vec<u8>, 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
}

View File

@@ -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,

View File

@@ -0,0 +1,166 @@
//! `SecretsService` — the v1.1.7 encrypted per-app secrets contract.
//!
//! Collection-less (per-app, like pubsub): the script API is the bare
//! `secrets::{get,set,delete,list}(name)` — there is no
//! `secrets::collection(...)`. Secrets are operational config (API keys,
//! OAuth tokens, webhook signing keys), encrypted at rest with the
//! process master key.
//!
//! Lives in `picloud-shared` (not `executor-core`) so the Rhai bridge,
//! the manager-core Postgres impl, and test fakes can all depend on the
//! same trait. Implementations MUST derive every storage `app_id` from
//! `cx.app_id` — never from a script-passed argument. That is the
//! cross-app isolation boundary; see `docs/sdk-shape.md`.
//!
//! Values are JSON internally: `set` accepts any `serde_json::Value`
//! (the bridge maps a Rhai String/Map/Array to JSON), encrypts the
//! encoded bytes, and `get` decrypts + decodes back to the same JSON
//! shape — so a String round-trips to a String, not a JSON-quoted
//! `"\"…\""`. There is deliberately **no `ServiceEvent` emission**:
//! firing triggers on secret writes is a footgun (every rotation would
//! fan out handler executions that might log the new value).
use async_trait::async_trait;
use thiserror::Error;
use crate::SdkCallCx;
/// Maximum secret name length in bytes (matches the brief: 255).
pub const SECRET_NAME_MAX_BYTES: usize = 255;
/// `SecretsService` is collection-less and per-app. Every method derives
/// the owning `app_id` from `cx.app_id`.
#[async_trait]
pub trait SecretsService: Send + Sync {
/// Decrypt and return the secret, or `None` if no secret with this
/// name exists for the app.
async fn get(
&self,
cx: &SdkCallCx,
name: &str,
) -> Result<Option<serde_json::Value>, SecretsError>;
/// Encrypt and store the secret, overwriting any existing value for
/// this name.
async fn set(
&self,
cx: &SdkCallCx,
name: &str,
value: serde_json::Value,
) -> Result<(), SecretsError>;
/// Delete the secret. Returns whether a secret was present.
async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError>;
/// List secret **names only** (never values), cursor-paginated like
/// KV/files `list`. `cursor` is opaque; `None` starts from the
/// beginning.
async fn list(
&self,
cx: &SdkCallCx,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsListPage, SecretsError>;
}
/// One page of secret names from `SecretsService::list`. `next_cursor`
/// is `Some` when more pages exist.
#[derive(Debug, Clone)]
pub struct SecretsListPage {
pub names: Vec<String>,
pub next_cursor: Option<String>,
}
/// Failure modes surfaced to the Rhai bridge. The bridge converts each
/// to a Rhai runtime error string.
#[derive(Debug, Error)]
pub enum SecretsError {
/// Empty name, or a name longer than [`SECRET_NAME_MAX_BYTES`].
#[error("{0}")]
InvalidName(String),
/// The encoded plaintext exceeded the configured per-secret cap.
#[error("secret value too large: {actual} bytes exceeds the {limit}-byte limit")]
TooLarge { limit: usize, actual: usize },
/// Caller principal lacked the required capability. Only raised when
/// `cx.principal.is_some()` — public-HTTP scripts (`principal: None`)
/// operate under script-as-gate semantics and skip the check.
#[error("forbidden")]
Forbidden,
/// The stored ciphertext could not be decrypted (corrupted row,
/// wrong master key, or tampering). The impl logs the affected
/// `(app_id, name)` at error level before returning this.
#[error("secret is corrupted or was encrypted with a different master key")]
Corrupted,
/// The process master key was unavailable. Startup should already
/// have failed; this is defense in depth.
#[error("master key is not configured")]
MasterKeyMissing,
/// Anything else — Postgres unavailable, serialization failure, etc.
#[error("secrets backend error: {0}")]
Backend(String),
}
/// Stub used by the executor-core test harness (which doesn't touch
/// secrets) so a `Services` bundle can be built without Postgres. Every
/// call returns `SecretsError::Backend(...)` so accidental use surfaces.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopSecretsService;
#[async_trait]
impl SecretsService for NoopSecretsService {
async fn get(
&self,
_cx: &SdkCallCx,
_name: &str,
) -> Result<Option<serde_json::Value>, SecretsError> {
Err(SecretsError::Backend("secrets is not wired in".into()))
}
async fn set(
&self,
_cx: &SdkCallCx,
_name: &str,
_value: serde_json::Value,
) -> Result<(), SecretsError> {
Err(SecretsError::Backend("secrets is not wired in".into()))
}
async fn delete(&self, _cx: &SdkCallCx, _name: &str) -> Result<bool, SecretsError> {
Err(SecretsError::Backend("secrets is not wired in".into()))
}
async fn list(
&self,
_cx: &SdkCallCx,
_cursor: Option<&str>,
_limit: u32,
) -> Result<SecretsListPage, SecretsError> {
Err(SecretsError::Backend("secrets is not wired in".into()))
}
}
/// Validate a secret name at the SDK/admin boundary: non-empty and at
/// most [`SECRET_NAME_MAX_BYTES`] bytes.
///
/// # Errors
///
/// Returns [`SecretsError::InvalidName`] when empty or too long.
pub fn validate_secret_name(name: &str) -> Result<(), SecretsError> {
if name.is_empty() {
return Err(SecretsError::InvalidName(
"secret name must not be empty".into(),
));
}
if name.len() > SECRET_NAME_MAX_BYTES {
return Err(SecretsError::InvalidName(format!(
"secret name must be at most {SECRET_NAME_MAX_BYTES} bytes, got {}",
name.len()
)));
}
Ok(())
}

View File

@@ -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<dyn PubsubService>,
/// Encrypted per-app secrets (v1.1.7). Scripts get
/// `secrets::{get,set,delete,list}(name)`. Backed by an
/// AES-256-GCM-at-rest Postgres repo in the picloud binary;
/// `NoopSecretsService` in tests that don't touch secrets.
pub secrets: Arc<dyn SecretsService>,
}
impl Services {
@@ -90,6 +97,7 @@ impl Services {
http: Arc<dyn HttpService>,
files: Arc<dyn FilesService>,
pubsub: Arc<dyn PubsubService>,
secrets: Arc<dyn SecretsService>,
) -> 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),
)
}
}

View File

@@ -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<string, string>;
@@ -714,6 +719,27 @@ export const api = {
)
},
secrets: {
// List returns names + last-modified ONLY — values never leave the
// server (v1.1.7).
list: (idOrSlug: string) =>
adminRequest<{ secrets: SecretListItem[]; next_cursor: string | null }>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/secrets`
),
// `value` is any JSON value; the dashboard sends a single-line
// string. Overwrites if the name already exists.
set: (idOrSlug: string, name: string, value: unknown) =>
adminRequest<null>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/secrets`, {
method: 'POST',
body: JSON.stringify({ name, value })
}),
remove: (idOrSlug: string, name: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/secrets/${encodeURIComponent(name)}`,
{ method: 'DELETE' }
)
},
execute: async (
id: string,
body: unknown,

View File

@@ -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<SecretListItem[]>([]);
let createSecretName = $state('');
let createSecretValue = $state('');
let showSecretValue = $state(false);
let revealConfirm = $state(false);
let creatingSecret = $state(false);
let createSecretError = $state<string | null>(null);
let secretToRemove = $state<SecretListItem | null>(null);
let removingSecret = $state(false);
// True when the name already exists — set is an overwrite.
const secretNameExists = $derived(
secrets.some((s) => s.name === createSecretName.trim())
);
async function loadSecrets(idOrSlug: string) {
try {
const r = await api.secrets.list(idOrSlug);
secrets = r.secrets;
} catch {
secrets = [];
}
}
function toggleShowSecretValue(e: Event) {
const target = e.currentTarget as HTMLInputElement;
if (target.checked) {
// Revealing a secret on screen is sensitive — gate behind a
// confirm. Revert the checkbox until the user confirms.
target.checked = false;
revealConfirm = true;
} else {
showSecretValue = false;
}
}
function confirmRevealSecret() {
showSecretValue = true;
revealConfirm = false;
}
async function submitCreateSecret(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingSecret = true;
createSecretError = null;
try {
await api.secrets.set(app.id, createSecretName.trim(), createSecretValue);
createSecretName = '';
createSecretValue = '';
showSecretValue = false;
await loadSecrets(app.id);
} catch (err) {
createSecretError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingSecret = false;
}
}
async function confirmRemoveSecret() {
if (!app || !secretToRemove) return;
removingSecret = true;
try {
await api.secrets.remove(app.id, secretToRemove.name);
secretToRemove = null;
await loadSecrets(app.id);
} catch (err) {
createSecretError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
removingSecret = false;
}
}
// Members tab
let eligibleUsers = $state<AdminDto[]>([]);
let eligibleLoadError = $state<string | null>(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})</button
>
<button
type="button"
class:active={activeTab === 'secrets'}
onclick={() => (activeTab = 'secrets')}>Secrets ({secrets.length})</button
>
<button
type="button"
class:active={activeTab === 'settings'}
@@ -1131,6 +1216,76 @@
</ul>
{/if}
</section>
{:else if activeTab === 'secrets' && canAdmin}
<section>
<h2>Secrets</h2>
<p class="muted">
Encrypted per-app configuration (API keys, OAuth tokens, webhook signing
keys), available to scripts as <code>secrets::get("name")</code>. Values are
encrypted at rest with the process master key and
<strong>never leave the server</strong> — this list shows names and
last-modified times only.
</p>
<form class="create-form" onsubmit={submitCreateSecret}>
<div class="row">
<label class="grow">
<span>Name</span>
<input bind:value={createSecretName} required placeholder="stripe_key" />
</label>
</div>
<label class="grow">
<span>Value</span>
{#if showSecretValue}
<input type="text" bind:value={createSecretValue} placeholder="sk_live_…" />
{:else}
<input type="password" bind:value={createSecretValue} placeholder="sk_live_…" />
{/if}
</label>
<label class="checkbox-row">
<input type="checkbox" checked={showSecretValue} onchange={toggleShowSecretValue} />
<span>Show value</span>
</label>
{#if secretNameExists && createSecretName.trim()}
<p class="muted small">
A secret named <code>{createSecretName.trim()}</code> already exists — saving
overwrites it.
</p>
{/if}
{#if createSecretError}
<div class="error">{createSecretError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creatingSecret || !createSecretName.trim()}>
{creatingSecret ? 'Saving…' : secretNameExists ? 'Overwrite secret' : 'Save secret'}
</button>
</div>
</form>
{#if secrets.length === 0}
<p class="muted">No secrets in this app yet.</p>
{:else}
<ul class="list">
{#each secrets as s (s.name)}
<li class="domain-row">
<div>
<code>{s.name}</code>
<span class="muted small">· updated {shortDate(s.updated_at)}</span>
</div>
<div class="topic-actions">
<button
type="button"
class="secondary danger"
onclick={() => (secretToRemove = s)}
>
Delete
</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'settings' && canAdmin}
<section>
<h2>Settings</h2>
@@ -1364,6 +1519,38 @@
</p>
</ConfirmModal>
{/if}
{#if revealConfirm}
<ConfirmModal
title="Reveal secret value?"
confirmLabel="Show value"
onConfirm={confirmRevealSecret}
onCancel={() => (revealConfirm = false)}
>
<p>
The value you type will be shown in plain text on screen. Make sure no one
is looking over your shoulder and that screen-sharing is off.
</p>
</ConfirmModal>
{/if}
{#if secretToRemove}
<ConfirmModal
title="Delete secret “{secretToRemove.name}”"
variant="danger"
confirmLabel="Delete secret"
busyLabel="Deleting…"
busy={removingSecret}
onConfirm={confirmRemoveSecret}
onCancel={() => (secretToRemove = null)}
>
<p>
Deleting <code>{secretToRemove.name}</code> is permanent. Any script calling
<code>secrets::get("{secretToRemove.name}")</code> will get <code>()</code>
until you set it again.
</p>
</ConfirmModal>
{/if}
{/if}
<style>