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>
This commit is contained in:
158
crates/manager-core/src/config_api.rs
Normal file
158
crates/manager-core/src/config_api.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
//! `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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user