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

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