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