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