feat(vars): admin CRUD API + pic vars CLI

Completes the vars half of Phase 3 end-to-end:
- vars_repo: VarOwner{Group|App} upsert/delete/list (owner-kind-specific
  SQL; ON CONFLICT restates the partial-index predicate).
- vars_api: GET/PUT/DELETE under /apps/{id}/vars and /groups/{id}/vars,
  resolve-then-require gated on App/GroupVars{Read,Write}, secrets-style
  error mapping + key/env-scope validation.
- pic vars ls/set/rm (--group|--app, --env, --json, --tombstone).
- journey test: a group var is inherited by a descendant app's
  vars::get(), and an app-level value overrides it (proximity) — green.

386 manager-core lib tests + the vars journey pass; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-24 21:11:46 +02:00
parent 343f6d3b4d
commit 9ee85993d8
11 changed files with 996 additions and 20 deletions

View File

@@ -0,0 +1,410 @@
//! `/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<dyn VarsRepo>,
pub apps: Arc<dyn AppRepository>,
pub groups: Arc<dyn GroupRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
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<String>,
/// 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<String>,
}
#[derive(Debug, serde::Serialize)]
struct VarItem {
key: String,
env: String,
value: serde_json::Value,
is_tombstone: bool,
updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, serde::Serialize)]
struct ListVarsResponse {
vars: Vec<VarItem>,
}
// ----------------------------------------------------------------------------
// App handlers
// ----------------------------------------------------------------------------
async fn list_app_vars(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<ListVarsResponse>, 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<VarsApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<SetVarRequest>,
) -> Result<StatusCode, VarsApiError> {
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<VarsApiState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, key)): Path<(String, String)>,
Query(q): Query<EnvQuery>,
) -> Result<StatusCode, VarsApiError> {
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<VarsApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<ListVarsResponse>, 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<VarsApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<SetVarRequest>,
) -> Result<StatusCode, VarsApiError> {
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<VarsApiState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, key)): Path<(String, String)>,
Query(q): Query<EnvQuery>,
) -> Result<StatusCode, VarsApiError> {
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<Json<ListVarsResponse>, 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<StatusCode, VarsApiError> {
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<StatusCode, VarsApiError> {
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<AppId, VarsApiError> {
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<GroupId, VarsApiError> {
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
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 1128 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 163 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<AuthzDenied> for VarsApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl From<AuthzError> for VarsApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl From<VarsRepoError> 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()
}
}