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:
24
crates/manager-core/migrations/0023_secrets.sql
Normal file
24
crates/manager-core/migrations/0023_secrets.sql
Normal 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);
|
||||
@@ -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!(
|
||||
|
||||
@@ -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};
|
||||
|
||||
232
crates/manager-core/src/secrets_api.rs
Normal file
232
crates/manager-core/src/secrets_api.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
246
crates/manager-core/src/secrets_repo.rs
Normal file
246
crates/manager-core/src/secrets_repo.rs
Normal 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 })
|
||||
}
|
||||
}
|
||||
574
crates/manager-core/src/secrets_service.rs
Normal file
574
crates/manager-core/src/secrets_service.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user