Finish the §11.6 KV slice: declarative authoring, read-only inspection, the
runtime journey, and docs — plus a forced SDK-name fix.
- SDK name: `shared` is a Rhai RESERVED keyword, so `kv::shared(...)` is a parse
error. Renamed the handle constructor to `kv::shared_collection(...)`
(keeps the user's "shared" intent; unambiguous). Updated everywhere.
- CLI manifest: `collections = [...]` on `[group]` only (ManifestApp has no such
field + deny_unknown_fields → an app manifest carrying it is a hard error);
Manifest::collections() accessor; build_bundle emits it.
- plan/apply: a `collection` row group in `pic plan`; created/deleted counts in
the apply summary; collections threaded through PlanDto/NodePlanDto/
ApplyReportDto.
- read-only `pic collections ls --group` → GET /admin/groups/{id}/collections
(viewer+), backed by ApplyService::collection_report + CollectionInfo.
- journey: a group declares `catalog`; app A writes, app B (same subtree) reads
it back; a sibling-subtree app gets CollectionNotShared; `collections ls` +
re-apply NoOp. Full suite 114/114.
- docs: groups-and-project-tool §11.6 (KV-only MVP shipped + deferrals),
sdk-shape.md (the shared-collection handle + trust model), CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
445 lines
15 KiB
Rust
445 lines
15 KiB
Rust
//! Admin HTTP surface for the declarative reconcile engine.
|
|
//!
|
|
//! `POST /api/v1/admin/apps/{id}/plan` — diff a desired-state bundle
|
|
//! against the app's live state and return the plan. Read-only; requires
|
|
//! `AppRead`. The `apply` route (write path) lands in the next milestone.
|
|
|
|
use axum::{
|
|
extract::{Path, State},
|
|
http::StatusCode,
|
|
response::{IntoResponse, Response},
|
|
routing::{get, post},
|
|
Extension, Json, Router,
|
|
};
|
|
use picloud_shared::{AppId, GroupId, Principal};
|
|
use serde::Deserialize;
|
|
use serde_json::json;
|
|
|
|
use crate::app_repo::AppRepository;
|
|
use crate::apply_service::{
|
|
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
|
|
ExtensionPointInfo, NodeKind, PlanResult, TreeBundle, TreePlanResult,
|
|
};
|
|
use crate::authz::{require, AuthzDenied, Capability};
|
|
use crate::group_repo::GroupRepository;
|
|
|
|
/// Build the apply/plan router. Mounted under `/api/v1/admin`.
|
|
pub fn apply_router(service: ApplyService) -> Router {
|
|
Router::new()
|
|
.route("/apps/{id}/plan", post(plan_handler))
|
|
.route("/apps/{id}/apply", post(apply_handler))
|
|
.route("/groups/{id}/plan", post(group_plan_handler))
|
|
.route("/groups/{id}/apply", post(group_apply_handler))
|
|
.route("/tree/plan", post(tree_plan_handler))
|
|
.route("/tree/apply", post(tree_apply_handler))
|
|
.route(
|
|
"/apps/{id}/extension-points",
|
|
get(app_extension_points_handler),
|
|
)
|
|
.route(
|
|
"/groups/{id}/extension-points",
|
|
get(group_extension_points_handler),
|
|
)
|
|
.route("/groups/{id}/collections", get(group_collections_handler))
|
|
.with_state(service)
|
|
}
|
|
|
|
/// Read-only §11.6 shared-collection report for a group: its own declared
|
|
/// shared KV collection names. Viewer-tier read.
|
|
async fn group_collections_handler(
|
|
State(svc): State<ApplyService>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
) -> Result<Json<Vec<CollectionInfo>>, ApplyError> {
|
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
|
require(
|
|
svc.authz.as_ref(),
|
|
&principal,
|
|
Capability::GroupScriptsRead(group_id),
|
|
)
|
|
.await
|
|
.map_err(map_authz)?;
|
|
let report = svc.collection_report(ApplyOwner::Group(group_id)).await?;
|
|
Ok(Json(report))
|
|
}
|
|
|
|
/// Read-only §5.5 extension-point report for an app: every EP visible on its
|
|
/// chain, each with `declared_here` + resolved `provider`. Viewer-tier read.
|
|
async fn app_extension_points_handler(
|
|
State(svc): State<ApplyService>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
) -> Result<Json<Vec<ExtensionPointInfo>>, ApplyError> {
|
|
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
|
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
|
.await
|
|
.map_err(map_authz)?;
|
|
let report = svc.extension_point_report(ApplyOwner::App(app_id)).await?;
|
|
Ok(Json(report))
|
|
}
|
|
|
|
/// Read-only §5.5 extension-point report for a group: its own declared names.
|
|
async fn group_extension_points_handler(
|
|
State(svc): State<ApplyService>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
) -> Result<Json<Vec<ExtensionPointInfo>>, ApplyError> {
|
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
|
require(
|
|
svc.authz.as_ref(),
|
|
&principal,
|
|
Capability::GroupScriptsRead(group_id),
|
|
)
|
|
.await
|
|
.map_err(map_authz)?;
|
|
let report = svc
|
|
.extension_point_report(ApplyOwner::Group(group_id))
|
|
.await?;
|
|
Ok(Json(report))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ApplyRequest {
|
|
pub bundle: Bundle,
|
|
#[serde(default)]
|
|
pub prune: bool,
|
|
/// Optional bound-plan token from a prior `plan`. When present, apply
|
|
/// refuses (409) if the app's live state has changed since.
|
|
#[serde(default)]
|
|
pub expected_token: Option<String>,
|
|
}
|
|
|
|
async fn apply_handler(
|
|
State(svc): State<ApplyService>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
Json(req): Json<ApplyRequest>,
|
|
) -> Result<Json<ApplyReport>, ApplyError> {
|
|
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
|
require_app_node_writes(&svc, &principal, app_id, &req.bundle, req.prune).await?;
|
|
let report = svc
|
|
.apply(
|
|
app_id,
|
|
&req.bundle,
|
|
req.prune,
|
|
principal.user_id,
|
|
req.expected_token.as_deref(),
|
|
)
|
|
.await?;
|
|
Ok(Json(report))
|
|
}
|
|
|
|
async fn plan_handler(
|
|
State(svc): State<ApplyService>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
Json(bundle): Json<Bundle>,
|
|
) -> Result<Json<PlanResult>, ApplyError> {
|
|
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
|
// NOTE: the returned `Plan` discloses live secret NAMES (not values). That
|
|
// is safe today only because `AppRead` and `AppSecretsRead` are co-granted
|
|
// at every tier (same `script:read` scope, both in the viewer role). If a
|
|
// future authz split puts `AppSecretsRead` on its own tier, this handler
|
|
// must additionally require it — otherwise it leaks names a principal
|
|
// couldn't enumerate via the secrets API.
|
|
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
|
.await
|
|
.map_err(map_authz)?;
|
|
let plan = svc.plan(app_id, &bundle).await?;
|
|
Ok(Json(plan))
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Group node apply (Phase 5): a group declares only scripts + vars (+ secret
|
|
// names). The bundle reuses the app `Bundle` shape with empty routes/triggers;
|
|
// the service rejects routes/triggers on a group node.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
async fn group_plan_handler(
|
|
State(svc): State<ApplyService>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
Json(bundle): Json<Bundle>,
|
|
) -> Result<Json<PlanResult>, ApplyError> {
|
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
|
// Plan discloses the group's script + var + secret names; viewer-tier reads.
|
|
require(
|
|
svc.authz.as_ref(),
|
|
&principal,
|
|
Capability::GroupScriptsRead(group_id),
|
|
)
|
|
.await
|
|
.map_err(map_authz)?;
|
|
let plan = svc.plan_owner(ApplyOwner::Group(group_id), &bundle).await?;
|
|
Ok(Json(plan))
|
|
}
|
|
|
|
async fn group_apply_handler(
|
|
State(svc): State<ApplyService>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
Json(req): Json<ApplyRequest>,
|
|
) -> Result<Json<ApplyReport>, ApplyError> {
|
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
|
require_group_node_writes(&svc, &principal, group_id, &req.bundle, req.prune).await?;
|
|
let report = svc
|
|
.apply_owner(
|
|
ApplyOwner::Group(group_id),
|
|
&req.bundle,
|
|
req.prune,
|
|
principal.user_id,
|
|
req.expected_token.as_deref(),
|
|
)
|
|
.await?;
|
|
Ok(Json(report))
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Tree apply (Phase 5): a whole project subtree in one transaction. The actor
|
|
// must hold the relevant read/write caps for EVERY node touched.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
#[derive(Deserialize)]
|
|
struct TreeApplyRequest {
|
|
bundle: TreeBundle,
|
|
#[serde(default)]
|
|
prune: bool,
|
|
#[serde(default)]
|
|
expected_token: Option<String>,
|
|
}
|
|
|
|
async fn tree_plan_handler(
|
|
State(svc): State<ApplyService>,
|
|
Extension(principal): Extension<Principal>,
|
|
Json(bundle): Json<TreeBundle>,
|
|
) -> Result<Json<TreePlanResult>, ApplyError> {
|
|
authz_tree(&svc, &principal, &bundle, None).await?;
|
|
Ok(Json(svc.plan_tree(&bundle).await?))
|
|
}
|
|
|
|
async fn tree_apply_handler(
|
|
State(svc): State<ApplyService>,
|
|
Extension(principal): Extension<Principal>,
|
|
Json(req): Json<TreeApplyRequest>,
|
|
) -> Result<Json<ApplyReport>, ApplyError> {
|
|
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
|
|
let report = svc
|
|
.apply_tree(
|
|
&req.bundle,
|
|
req.prune,
|
|
principal.user_id,
|
|
req.expected_token.as_deref(),
|
|
)
|
|
.await?;
|
|
Ok(Json(report))
|
|
}
|
|
|
|
/// Per-node capability check for a tree plan/apply. `prune` widens an apply's
|
|
/// write requirement to every kind. A plan (`prune` ignored, no writes) needs
|
|
/// only the per-node read cap.
|
|
async fn authz_tree(
|
|
svc: &ApplyService,
|
|
principal: &Principal,
|
|
bundle: &TreeBundle,
|
|
apply_prune: Option<bool>,
|
|
) -> Result<(), ApplyError> {
|
|
for node in &bundle.nodes {
|
|
match node.kind {
|
|
NodeKind::App => {
|
|
let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
|
|
match apply_prune {
|
|
Some(prune) => {
|
|
require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
|
|
.await?;
|
|
}
|
|
None => {
|
|
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
|
.await
|
|
.map_err(map_authz)?;
|
|
}
|
|
}
|
|
}
|
|
NodeKind::Group => {
|
|
let group_id = resolve_group_id(svc.groups.as_ref(), &node.slug).await?;
|
|
match apply_prune {
|
|
Some(prune) => {
|
|
require_group_node_writes(svc, principal, group_id, &node.bundle, prune)
|
|
.await?;
|
|
}
|
|
None => {
|
|
require(
|
|
svc.authz.as_ref(),
|
|
principal,
|
|
Capability::GroupScriptsRead(group_id),
|
|
)
|
|
.await
|
|
.map_err(map_authz)?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// App-node write caps: per resource kind the bundle touches, widened to all
|
|
/// kinds when `prune` (which deletes empty-section resources + cascades). Read
|
|
/// is always needed. Shared by the single-app and tree apply paths.
|
|
async fn require_app_node_writes(
|
|
svc: &ApplyService,
|
|
principal: &Principal,
|
|
app_id: picloud_shared::AppId,
|
|
bundle: &Bundle,
|
|
prune: bool,
|
|
) -> Result<(), ApplyError> {
|
|
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
|
.await
|
|
.map_err(map_authz)?;
|
|
if prune || !bundle.scripts.is_empty() {
|
|
require(
|
|
svc.authz.as_ref(),
|
|
principal,
|
|
Capability::AppWriteScript(app_id),
|
|
)
|
|
.await
|
|
.map_err(map_authz)?;
|
|
}
|
|
if prune || !bundle.routes.is_empty() {
|
|
require(
|
|
svc.authz.as_ref(),
|
|
principal,
|
|
Capability::AppWriteRoute(app_id),
|
|
)
|
|
.await
|
|
.map_err(map_authz)?;
|
|
}
|
|
if prune || !bundle.triggers.is_empty() {
|
|
require(
|
|
svc.authz.as_ref(),
|
|
principal,
|
|
Capability::AppManageTriggers(app_id),
|
|
)
|
|
.await
|
|
.map_err(map_authz)?;
|
|
}
|
|
if prune || !bundle.vars.is_empty() {
|
|
require(
|
|
svc.authz.as_ref(),
|
|
principal,
|
|
Capability::AppVarsWrite(app_id),
|
|
)
|
|
.await
|
|
.map_err(map_authz)?;
|
|
}
|
|
// Email triggers resolve + decrypt a stored secret by name server-side
|
|
// (`AppSecretsRead`), so require it so apply can't bind a secret the
|
|
// principal couldn't otherwise read.
|
|
if bundle.triggers.iter().any(BundleTrigger::is_email) {
|
|
require(
|
|
svc.authz.as_ref(),
|
|
principal,
|
|
Capability::AppSecretsRead(app_id),
|
|
)
|
|
.await
|
|
.map_err(map_authz)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Group-node write caps (editor-tier): scripts → `GroupScriptsWrite`, vars →
|
|
/// `GroupVarsWrite`, both on prune.
|
|
async fn require_group_node_writes(
|
|
svc: &ApplyService,
|
|
principal: &Principal,
|
|
group_id: GroupId,
|
|
bundle: &Bundle,
|
|
prune: bool,
|
|
) -> Result<(), ApplyError> {
|
|
if prune || !bundle.scripts.is_empty() {
|
|
require(
|
|
svc.authz.as_ref(),
|
|
principal,
|
|
Capability::GroupScriptsWrite(group_id),
|
|
)
|
|
.await
|
|
.map_err(map_authz)?;
|
|
}
|
|
if prune || !bundle.vars.is_empty() {
|
|
require(
|
|
svc.authz.as_ref(),
|
|
principal,
|
|
Capability::GroupVarsWrite(group_id),
|
|
)
|
|
.await
|
|
.map_err(map_authz)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
|
|
async fn resolve_group_id(
|
|
groups: &dyn GroupRepository,
|
|
ident: &str,
|
|
) -> Result<GroupId, ApplyError> {
|
|
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
|
groups
|
|
.get_by_id(uuid.into())
|
|
.await
|
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
|
} else {
|
|
groups
|
|
.get_by_slug(ident)
|
|
.await
|
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
|
};
|
|
found
|
|
.map(|g| g.id)
|
|
.ok_or_else(|| ApplyError::AppNotFound(format!("group {ident}")))
|
|
}
|
|
|
|
/// Resolve a slug-or-id path param to an `AppId`, mapping miss → 404.
|
|
/// Mirrors the `triggers_api` helper of the same shape.
|
|
async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApplyError> {
|
|
crate::app_repo::resolve_app(apps, ident)
|
|
.await
|
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
|
.map(|l| l.app.id)
|
|
.ok_or_else(|| ApplyError::AppNotFound(ident.to_string()))
|
|
}
|
|
|
|
fn map_authz(denied: AuthzDenied) -> ApplyError {
|
|
match denied {
|
|
AuthzDenied::Denied => ApplyError::Forbidden,
|
|
AuthzDenied::Repo(e) => ApplyError::AuthzRepo(e.to_string()),
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for ApplyError {
|
|
fn into_response(self) -> Response {
|
|
let (status, body) = match &self {
|
|
Self::AppNotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
|
|
Self::Invalid(_) => (
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
json!({ "error": self.to_string() }),
|
|
),
|
|
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
|
Self::AuthzRepo(e) => {
|
|
tracing::error!(error = %e, "apply authz repo error");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
json!({ "error": "internal error" }),
|
|
)
|
|
}
|
|
Self::Backend(e) => {
|
|
tracing::error!(error = %e, "apply backend error");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
json!({ "error": "internal error" }),
|
|
)
|
|
}
|
|
};
|
|
(status, Json(body)).into_response()
|
|
}
|
|
}
|