//! `/api/v1/admin/{apps,groups}/{id_or_slug}/vars*` — the Phase-3 config //! `vars` admin surface (write/list side; resolution lives in //! `config_resolver` + the `vars::` SDK). //! //! * `GET /apps/{id}/vars` — list the app's OWN vars. //! * `PUT /apps/{id}/vars` — set/overwrite one app var. //! * `DELETE /apps/{id}/vars/{key}` — delete one app var. //! * `GET/PUT/DELETE /groups/{id}/vars[...]` — same, group-owned. //! //! App routes gate on `App{Vars}Read/Write`; group routes on //! `Group{Vars}Read/Write`. 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. Listing returns the owner's //! OWN rows only (not the resolved/inherited view). use std::sync::Arc; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::{get, put}; use axum::{Extension, Router}; use picloud_shared::{AppId, GroupId, Principal}; 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::vars_repo::{VarOwner, VarsRepo, VarsRepoError}; #[derive(Clone)] pub struct VarsApiState { pub vars: Arc, pub apps: Arc, pub groups: Arc, pub authz: Arc, } pub fn vars_router(state: VarsApiState) -> Router { Router::new() .route( "/apps/{id_or_slug}/vars", get(list_app_vars).put(set_app_var), ) .route( "/apps/{id_or_slug}/vars/{key}", axum::routing::delete(delete_app_var), ) .route( "/groups/{id_or_slug}/vars", put(set_group_var).get(list_group_vars), ) .route( "/groups/{id_or_slug}/vars/{key}", axum::routing::delete(delete_group_var), ) .with_state(state) } // ---------------------------------------------------------------------------- // DTOs // ---------------------------------------------------------------------------- #[derive(Debug, Deserialize)] pub struct SetVarRequest { pub key: String, pub value: serde_json::Value, /// Environment scope — `*` (env-agnostic, default) or a concrete env /// name matched against `apps.environment` at resolution time. #[serde(default)] pub env: Option, /// Write a tombstone (suppresses an inherited key) instead of a real /// value. The body's `value` is ignored for a tombstone. #[serde(default)] pub tombstone: bool, } #[derive(Debug, Deserialize)] pub struct EnvQuery { #[serde(default)] pub env: Option, } #[derive(Debug, serde::Serialize)] struct VarItem { key: String, env: String, value: serde_json::Value, is_tombstone: bool, updated_at: chrono::DateTime, } #[derive(Debug, serde::Serialize)] struct ListVarsResponse { vars: Vec, } // ---------------------------------------------------------------------------- // App handlers // ---------------------------------------------------------------------------- async fn list_app_vars( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result, VarsApiError> { let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, Capability::AppVarsRead(app_id), ) .await?; list(&*s.vars, VarOwner::App(app_id)).await } async fn set_app_var( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, Json(input): Json, ) -> Result { let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, Capability::AppVarsWrite(app_id), ) .await?; set(&*s.vars, VarOwner::App(app_id), input).await } async fn delete_app_var( State(s): State, Extension(principal): Extension, Path((id_or_slug, key)): Path<(String, String)>, Query(q): Query, ) -> Result { let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, Capability::AppVarsWrite(app_id), ) .await?; delete(&*s.vars, VarOwner::App(app_id), &key, q.env.as_deref()).await } // ---------------------------------------------------------------------------- // Group handlers // ---------------------------------------------------------------------------- async fn list_group_vars( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result, VarsApiError> { let group_id = resolve_group(&*s.groups, &id_or_slug).await?; require( s.authz.as_ref(), &principal, Capability::GroupVarsRead(group_id), ) .await?; list(&*s.vars, VarOwner::Group(group_id)).await } async fn set_group_var( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, Json(input): Json, ) -> Result { let group_id = resolve_group(&*s.groups, &id_or_slug).await?; require( s.authz.as_ref(), &principal, Capability::GroupVarsWrite(group_id), ) .await?; set(&*s.vars, VarOwner::Group(group_id), input).await } async fn delete_group_var( State(s): State, Extension(principal): Extension, Path((id_or_slug, key)): Path<(String, String)>, Query(q): Query, ) -> Result { let group_id = resolve_group(&*s.groups, &id_or_slug).await?; require( s.authz.as_ref(), &principal, Capability::GroupVarsWrite(group_id), ) .await?; delete(&*s.vars, VarOwner::Group(group_id), &key, q.env.as_deref()).await } // ---------------------------------------------------------------------------- // Shared owner-generic bodies // ---------------------------------------------------------------------------- async fn list( vars: &dyn VarsRepo, owner: VarOwner, ) -> Result, VarsApiError> { let rows = vars.list_for_owner(owner).await?; Ok(Json(ListVarsResponse { vars: rows .into_iter() .map(|r| VarItem { key: r.key, env: r.environment_scope, value: r.value, is_tombstone: r.is_tombstone, updated_at: r.updated_at, }) .collect(), })) } async fn set( vars: &dyn VarsRepo, owner: VarOwner, input: SetVarRequest, ) -> Result { validate_key(&input.key)?; let env = input.env.as_deref().unwrap_or("*"); validate_env_scope(env)?; // A tombstone carries no meaningful value (the resolver suppresses the // key regardless); store JSON null so the NOT NULL column is satisfied. let value = if input.tombstone { serde_json::Value::Null } else { input.value }; vars.set(owner, env, &input.key, &value, input.tombstone) .await?; Ok(StatusCode::NO_CONTENT) } async fn delete( vars: &dyn VarsRepo, owner: VarOwner, key: &str, env: Option<&str>, ) -> Result { let env = env.unwrap_or("*"); validate_env_scope(env)?; if !vars.delete(owner, env, key).await? { return Err(VarsApiError::NotFound); } Ok(StatusCode::NO_CONTENT) } // ---------------------------------------------------------------------------- // Resolution + validation // ---------------------------------------------------------------------------- async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result { crate::app_repo::resolve_app(apps, ident) .await .map_err(|e| VarsApiError::Backend(e.to_string()))? .map(|l| l.app.id) .ok_or(VarsApiError::AppNotFound) } async fn resolve_group(groups: &dyn GroupRepository, ident: &str) -> Result { let found = if let Ok(uuid) = ident.parse::() { groups .get_by_id(uuid.into()) .await .map_err(|e| VarsApiError::Backend(e.to_string()))? } else { groups .get_by_slug(ident) .await .map_err(|e| VarsApiError::Backend(e.to_string()))? }; found.map(|g| g.id).ok_or(VarsApiError::GroupNotFound) } /// Keys are kebab identifiers (`^[a-z0-9][a-z0-9-]*$`) — same shape as the /// manifest's var names (docs/design §4.3). fn validate_key(key: &str) -> Result<(), VarsApiError> { if key.is_empty() || key.len() > 128 { return Err(VarsApiError::Invalid("key must be 1–128 characters".into())); } let mut chars = key.chars(); let first = chars.next().unwrap(); if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { return Err(VarsApiError::Invalid( "key must start with a lowercase letter or digit".into(), )); } if !key .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { return Err(VarsApiError::Invalid( "key may contain only lowercase letters, digits, and hyphens".into(), )); } Ok(()) } /// Env scope is `*` (env-agnostic) or a kebab env name. fn validate_env_scope(env: &str) -> Result<(), VarsApiError> { if env == "*" { return Ok(()); } if env.is_empty() || env.len() > 63 { return Err(VarsApiError::Invalid( "env must be '*' or 1–63 characters".into(), )); } let mut chars = env.chars(); let first = chars.next().unwrap(); if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { return Err(VarsApiError::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(VarsApiError::Invalid( "env may contain only lowercase letters, digits, and hyphens".into(), )); } Ok(()) } // ---------------------------------------------------------------------------- // Errors // ---------------------------------------------------------------------------- #[derive(Debug, thiserror::Error)] pub enum VarsApiError { #[error("app not found")] AppNotFound, #[error("group not found")] GroupNotFound, #[error("var not found")] NotFound, #[error("invalid request: {0}")] Invalid(String), #[error("forbidden")] Forbidden, #[error("authorization repo error: {0}")] AuthzRepo(String), #[error("vars backend: {0}")] Backend(String), } impl From for VarsApiError { fn from(d: AuthzDenied) -> Self { match d { AuthzDenied::Denied => Self::Forbidden, AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), } } } impl From for VarsApiError { fn from(e: AuthzError) -> Self { Self::AuthzRepo(e.to_string()) } } impl From for VarsApiError { fn from(e: VarsRepoError) -> Self { match e { VarsRepoError::Db(e) => Self::Backend(e.to_string()), } } } impl IntoResponse for VarsApiError { 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, "vars admin authz repo error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } Self::Backend(e) => { tracing::error!(error = %e, "vars admin backend error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } }; (status, Json(body)).into_response() } }