//! `/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, pub apps: Arc, pub authz: Arc, 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, #[serde(default)] pub limit: Option, } #[derive(Debug, serde::Serialize)] struct SecretItem { name: String, updated_at: chrono::DateTime, } #[derive(Debug, serde::Serialize)] struct ListSecretsResponse { secrets: Vec, next_cursor: Option, } async fn list_secrets( State(s): State, Extension(principal): Extension, Path(app_id): Path, Query(q): Query, ) -> Result, 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, Extension(principal): Extension, Path(app_id): Path, Json(input): Json, ) -> Result { 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, Extension(principal): Extension, Path((app_id, name)): Path<(AppId, String)>, ) -> Result { 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 for SecretsApiError { fn from(d: AuthzDenied) -> Self { match d { AuthzDenied::Denied => Self::Forbidden, AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), } } } impl From for SecretsApiError { fn from(e: AuthzError) -> Self { Self::AuthzRepo(e.to_string()) } } impl From 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 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() } }