Files
PiCloud/crates/manager-core/src/secrets_api.rs
MechaCat02 c914758c09 feat(secrets): group-secrets admin API, masked read, config/effective
Adds the Phase-3 admin surface on top of the group-secrets storage:

* `secrets_api` gains group routes under `/groups/{id}/secrets`
  (set/list/delete, env-scoped) gated `GroupSecretsWrite` (editor+), plus
  the ONE plaintext endpoint `GET /groups/{id}/secrets/{name}/value` gated
  `GroupSecretsRead` (group_admin only). That is the masked-secret
  boundary: a descendant app's dev sees a group secret EXISTS and consumes
  it at runtime via `secrets::get`, but only a reader at the OWNING group
  gets the value. App secrets stay env-agnostic (a stray `env` is rejected).
  The owner is resolved first, then the capability binds to the resolved
  id — never a path param.

* `config_api`: `GET /apps/{id}/config/effective` (gated `AppVarsRead`)
  returns the resolved view a dev would get — every inherited var with its
  value + provenance, and every inherited secret MASKED (name/owner/scope,
  never the value). Backed by a new `fetch_effective_secret_meta`
  (DISTINCT-ON nearest-wins, same ordering as the per-name resolver).

* authz: `GroupSecretsWrite` moves from `app:admin` to `script:write`
  scope so its API-key scope matches its editor role tier (closing the
  latent scope/role mismatch the checkpoint review flagged); the value
  read `GroupSecretsRead` stays at `app:admin`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:00:32 +02:00

468 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `/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<dyn SecretsRepo>,
pub apps: Arc<dyn AppRepository>,
pub groups: Arc<dyn GroupRepository>,
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_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<String>,
#[serde(default)]
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize)]
pub struct EnvQuery {
#[serde(default)]
pub env: Option<String>,
}
#[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<chrono::Utc>,
}
#[derive(Debug, serde::Serialize)]
struct ListSecretsResponse {
secrets: Vec<SecretItem>,
next_cursor: Option<String>,
}
#[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<String>,
}
// ----------------------------------------------------------------------------
// App handlers (env-agnostic, scope `*`)
// ----------------------------------------------------------------------------
async fn list_app_secrets(
State(s): State<SecretsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Query(q): Query<ListQuery>,
) -> Result<Json<ListSecretsResponse>, 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<SecretsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<SetSecretRequest>,
) -> Result<StatusCode, SecretsApiError> {
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<SecretsState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, name)): Path<(String, String)>,
) -> Result<StatusCode, SecretsApiError> {
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<SecretsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Query(q): Query<ListQuery>,
) -> Result<Json<ListSecretsResponse>, 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<SecretsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<SetSecretRequest>,
) -> Result<StatusCode, SecretsApiError> {
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<SecretsState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, name)): Path<(String, String)>,
Query(q): Query<EnvQuery>,
) -> Result<StatusCode, SecretsApiError> {
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<SecretsState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, name)): Path<(String, String)>,
Query(q): Query<EnvQuery>,
) -> Result<Json<serde_json::Value>, 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<Json<ListSecretsResponse>, 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<StatusCode, SecretsApiError> {
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<StatusCode, SecretsApiError> {
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<AppId, SecretsApiError> {
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<GroupId, SecretsApiError> {
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
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 163 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<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::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()
}
}