//! `/api/v1/admin/{apps,groups}/{id}/secrets*` — secrets admin endpoints. //! //! * `GET /apps/{id}/secrets` — list app secret names + updated_at //! (NEVER values). //! * `POST /apps/{id}/secrets` — set/overwrite an app secret. //! * `DELETE /apps/{id}/secrets/{name}` — delete an app secret. //! * `GET /groups/{id}/secrets` — list group secret names + scope //! + updated_at (NEVER values). //! * `POST /groups/{id}/secrets` — set/overwrite a group secret //! (env-scoped). //! * `DELETE /groups/{id}/secrets/{name}` — delete a group secret. //! * `GET /groups/{id}/secrets/{name}/value` — **human value read** — //! the ONE endpoint that returns plaintext, gated at the owning group. //! //! App set/delete are gated by `AppSecretsWrite`, list by `AppSecretsRead`. //! Group set/list/delete are gated by `GroupSecretsWrite` (editor+); the //! value-read is gated by `GroupSecretsRead` (group_admin only) — that is //! the masked-secret boundary: a descendant app's dev can see that a group //! secret EXISTS (and consume it at runtime via `secrets::get`) but only a //! principal with read rights AT THE OWNING GROUP can read its value. The //! owner is resolved FIRST (slug-or-uuid), THEN `authz::require` binds the //! capability to the resolved owner id — never to a caller-controlled path //! param. Values are encrypted with the process master key before they //! touch the database (owner-bound AAD; see `secrets_service`). 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, GroupId, 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::group_repo::GroupRepository; use crate::secrets_repo::{SecretOwner, SecretsRepo, SecretsRepoError}; use crate::secrets_service::{open, seal}; /// App secrets are env-agnostic; only group secrets carry a concrete scope. const APP_SECRET_SCOPE: &str = "*"; #[derive(Clone)] pub struct SecretsState { pub repo: Arc, pub apps: Arc, pub groups: 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_app_secrets).post(set_app_secret), ) .route( "/apps/{app_id}/secrets/{name}", axum::routing::delete(delete_app_secret), ) .route( "/groups/{group_id}/secrets", get(list_group_secrets).post(set_group_secret), ) .route( "/groups/{group_id}/secrets/{name}", axum::routing::delete(delete_group_secret), ) .route( "/groups/{group_id}/secrets/{name}/value", get(read_group_secret_value), ) .with_state(state) } #[derive(Debug, Deserialize)] pub struct ListQuery { #[serde(default)] pub cursor: Option, #[serde(default)] pub limit: Option, } #[derive(Debug, Deserialize)] pub struct EnvQuery { #[serde(default)] pub env: Option, } #[derive(Debug, serde::Serialize)] struct SecretItem { name: String, /// Environment scope — `*` for app secrets, possibly a concrete env for /// group secrets. env: String, updated_at: chrono::DateTime, } #[derive(Debug, serde::Serialize)] struct ListSecretsResponse { secrets: Vec, next_cursor: Option, } #[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, /// Environment scope (group secrets only). `*` (env-agnostic, default) /// or a concrete env matched against `apps.environment` at resolution. #[serde(default)] pub env: Option, } // ---------------------------------------------------------------------------- // App handlers (env-agnostic, scope `*`) // ---------------------------------------------------------------------------- async fn list_app_secrets( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, Query(q): Query, ) -> Result, SecretsApiError> { let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, Capability::AppSecretsRead(app_id), ) .await?; list_meta(&*s.repo, SecretOwner::App(app_id), &q).await } async fn set_app_secret( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, Json(input): Json, ) -> Result { let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, Capability::AppSecretsWrite(app_id), ) .await?; // App secrets are always env-agnostic; reject a stray `env`. if input.env.as_deref().is_some_and(|e| e != APP_SECRET_SCOPE) { return Err(SecretsApiError::Invalid( "app secrets are env-agnostic; set an environment scope on a group secret instead" .into(), )); } seal_and_store(&s, SecretOwner::App(app_id), APP_SECRET_SCOPE, input).await } async fn delete_app_secret( State(s): State, Extension(principal): Extension, Path((id_or_slug, name)): Path<(String, String)>, ) -> Result { let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, Capability::AppSecretsWrite(app_id), ) .await?; delete(&*s.repo, SecretOwner::App(app_id), APP_SECRET_SCOPE, &name).await } // ---------------------------------------------------------------------------- // Group handlers (env-scoped) // ---------------------------------------------------------------------------- async fn list_group_secrets( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, Query(q): Query, ) -> Result, SecretsApiError> { let group_id = resolve_group(&*s.groups, &id_or_slug).await?; require( s.authz.as_ref(), &principal, Capability::GroupSecretsWrite(group_id), ) .await?; list_meta(&*s.repo, SecretOwner::Group(group_id), &q).await } async fn set_group_secret( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, Json(input): Json, ) -> Result { let group_id = resolve_group(&*s.groups, &id_or_slug).await?; require( s.authz.as_ref(), &principal, Capability::GroupSecretsWrite(group_id), ) .await?; let env = input.env.clone().unwrap_or_else(|| APP_SECRET_SCOPE.into()); validate_env_scope(&env)?; seal_and_store(&s, SecretOwner::Group(group_id), &env, input).await } async fn delete_group_secret( State(s): State, Extension(principal): Extension, Path((id_or_slug, name)): Path<(String, String)>, Query(q): Query, ) -> Result { let group_id = resolve_group(&*s.groups, &id_or_slug).await?; require( s.authz.as_ref(), &principal, Capability::GroupSecretsWrite(group_id), ) .await?; let env = q.env.unwrap_or_else(|| APP_SECRET_SCOPE.into()); validate_env_scope(&env)?; delete(&*s.repo, SecretOwner::Group(group_id), &env, &name).await } /// The ONE plaintext-returning endpoint. Gated by `GroupSecretsRead` /// (group_admin) — the masked-secret boundary. A descendant app's dev can /// see the secret exists and consume it at runtime, but only a reader at the /// owning group gets the value here. async fn read_group_secret_value( State(s): State, Extension(principal): Extension, Path((id_or_slug, name)): Path<(String, String)>, Query(q): Query, ) -> Result, SecretsApiError> { let group_id = resolve_group(&*s.groups, &id_or_slug).await?; require( s.authz.as_ref(), &principal, Capability::GroupSecretsRead(group_id), ) .await?; validate_secret_name(&name)?; let env = q.env.unwrap_or_else(|| APP_SECRET_SCOPE.into()); validate_env_scope(&env)?; let owner = SecretOwner::Group(group_id); let stored = s .repo .get(owner, &env, &name) .await? .ok_or(SecretsApiError::NotFound)?; let value = open(&s.master_key, owner, &name, &stored).map_err(|e| { tracing::error!(group_id = %group_id, secret = %name, "group secret could not be decrypted"); SecretsApiError::from(e) })?; Ok(Json(json!({ "name": name, "env": env, "value": value }))) } // ---------------------------------------------------------------------------- // Shared owner-generic bodies // ---------------------------------------------------------------------------- async fn list_meta( repo: &dyn SecretsRepo, owner: SecretOwner, q: &ListQuery, ) -> Result, SecretsApiError> { let page = repo .list_meta(owner, q.cursor.as_deref(), q.limit.unwrap_or(0)) .await?; Ok(Json(ListSecretsResponse { secrets: page .items .into_iter() .map(|m| SecretItem { name: m.name, env: m.environment_scope, updated_at: m.updated_at, }) .collect(), next_cursor: page.next_cursor, })) } async fn seal_and_store( s: &SecretsState, owner: SecretOwner, env: &str, input: SetSecretRequest, ) -> Result { validate_secret_name(&input.name)?; // Audit 2026-06-11 H-D1 — v1 envelope with AAD bound to the owner+name. let (ciphertext, nonce, version) = seal( &s.master_key, owner, &input.name, &input.value, s.max_value_bytes, )?; s.repo .set(owner, env, &input.name, &ciphertext, &nonce, version) .await?; Ok(StatusCode::NO_CONTENT) } async fn delete( repo: &dyn SecretsRepo, owner: SecretOwner, env: &str, name: &str, ) -> Result { if !repo.delete(owner, env, name).await? { return Err(SecretsApiError::NotFound); } Ok(StatusCode::NO_CONTENT) } // ---------------------------------------------------------------------------- // Resolution + validation // ---------------------------------------------------------------------------- async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result { crate::app_repo::resolve_app(apps, ident) .await .map_err(|e| SecretsApiError::Backend(e.to_string()))? .map(|l| l.app.id) .ok_or(SecretsApiError::AppNotFound) } async fn resolve_group( groups: &dyn GroupRepository, ident: &str, ) -> Result { let found = if let Ok(uuid) = ident.parse::() { groups .get_by_id(uuid.into()) .await .map_err(|e| SecretsApiError::Backend(e.to_string()))? } else { groups .get_by_slug(ident) .await .map_err(|e| SecretsApiError::Backend(e.to_string()))? }; found.map(|g| g.id).ok_or(SecretsApiError::GroupNotFound) } /// Env scope is `*` (env-agnostic) or a kebab env name. Mirrors the vars /// admin validator so a secret and a var share the same env vocabulary. fn validate_env_scope(env: &str) -> Result<(), SecretsApiError> { if env == "*" { return Ok(()); } if env.is_empty() || env.len() > 63 { return Err(SecretsApiError::Invalid( "env must be '*' or 1–63 characters".into(), )); } let first = env.chars().next().unwrap(); if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { return Err(SecretsApiError::Invalid( "env must start with a lowercase letter or digit".into(), )); } if !env .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { return Err(SecretsApiError::Invalid( "env may contain only lowercase letters, digits, and hyphens".into(), )); } Ok(()) } #[derive(Debug, thiserror::Error)] pub enum SecretsApiError { #[error("app not found")] AppNotFound, #[error("group not found")] GroupNotFound, #[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::GroupNotFound | 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() } }