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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
166
crates/shared/src/secrets.rs
Normal file
166
crates/shared/src/secrets.rs
Normal 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(())
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user