Files
PiCloud/crates/manager-core/src/config_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

159 lines
5.2 KiB
Rust

//! `GET /api/v1/admin/apps/{id}/config/effective` — the resolved config an
//! app actually sees: every inherited var (with its value + provenance) and
//! every inherited secret (MASKED — name/owner/scope only, never the value).
//!
//! This is the read-only companion to the `vars`/`secrets` admin surfaces.
//! It runs the same §3 resolution the `vars::`/`secrets::` SDK calls run, so
//! a dev can see exactly what `vars::get`/`secrets::get` would return and
//! where each value comes from (`--explain` on the CLI surfaces the
//! provenance). Gated by `AppVarsRead` (config is app-readable); the secret
//! VALUES are deliberately absent — reading those needs `GroupSecretsRead`
//! at the owning group via the dedicated value endpoint.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use picloud_shared::{AppId, Principal};
use serde_json::json;
use sqlx::PgPool;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::config_resolver::{
fetch_effective_secret_meta, fetch_var_candidates, resolve, OwnerKind,
};
#[derive(Clone)]
pub struct ConfigApiState {
pub pool: PgPool,
pub apps: Arc<dyn AppRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
pub fn config_router(state: ConfigApiState) -> Router {
Router::new()
.route("/apps/{id_or_slug}/config/effective", get(effective_config))
.with_state(state)
}
fn owner_json(kind: OwnerKind, id: uuid::Uuid, depth: i32) -> serde_json::Value {
json!({ "kind": kind.as_str(), "id": id, "depth": depth })
}
async fn effective_config(
State(s): State<ConfigApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<serde_json::Value>, ConfigApiError> {
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppVarsRead(app_id),
)
.await?;
// Vars: resolve to values + provenance (vars are app-readable config).
let candidates = fetch_var_candidates(&s.pool, app_id)
.await
.map_err(|e| ConfigApiError::Backend(e.to_string()))?;
let (values, provenance) = resolve(candidates);
let mut vars = serde_json::Map::new();
for (key, value) in values {
let p = &provenance[&key];
vars.insert(
key,
json!({
"value": value,
"owner": owner_json(p.owner_kind, p.owner_id, p.depth),
"scope": p.scope,
"merged_from": p.merged_from
.iter()
.map(|(d, sc)| json!({ "depth": d, "scope": sc }))
.collect::<Vec<_>>(),
}),
);
}
// Secrets: masked — name + owner/level/scope + status, never the value.
let secret_meta = fetch_effective_secret_meta(&s.pool, app_id)
.await
.map_err(|e| ConfigApiError::Backend(e.to_string()))?;
let mut secrets = serde_json::Map::new();
for m in secret_meta {
secrets.insert(
m.name,
json!({
"status": "set",
"owner": owner_json(m.owner_kind, m.owner_id, m.depth),
"scope": m.scope,
}),
);
}
Ok(Json(json!({ "vars": vars, "secrets": secrets })))
}
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ConfigApiError> {
crate::app_repo::resolve_app(apps, ident)
.await
.map_err(|e| ConfigApiError::Backend(e.to_string()))?
.map(|l| l.app.id)
.ok_or(ConfigApiError::AppNotFound)
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigApiError {
#[error("app not found")]
AppNotFound,
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("config backend: {0}")]
Backend(String),
}
impl From<AuthzDenied> for ConfigApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl From<AuthzError> for ConfigApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl IntoResponse for ConfigApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "config effective authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Backend(e) => {
tracing::error!(error = %e, "config effective backend error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}