//! `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, pub authz: Arc, } 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, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result, 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::>(), }), ); } // 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 { 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 for ConfigApiError { fn from(d: AuthzDenied) -> Self { match d { AuthzDenied::Denied => Self::Forbidden, AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), } } } impl From 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() } }