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>
159 lines
5.2 KiB
Rust
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()
|
|
}
|
|
}
|