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>
167 lines
5.8 KiB
Rust
167 lines
5.8 KiB
Rust
//! `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(())
|
|
}
|