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>
411 lines
13 KiB
Rust
411 lines
13 KiB
Rust
//! `/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 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<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()
|
||
}
|
||
}
|