Closes the gaps and the one security finding from the second end-to-end
CLI test (E2E_STASH_REPORT.md), plus the H1 boot-regression found while
re-reviewing those fixes.
Security
- S6: reserved-path validation (`check_reserved`) now case-folds before
comparing, so `/API/v2/x`, `/HEALTHZ`, `/Admin/x` are rejected like
their lowercase forms. Request-time matching stays case-sensitive.
- S10: "public route != public data" callout in sdk-shape.md (script_gate
skips authz when the principal is anonymous).
Observability / features
- G1: trigger executions now write `execution_logs`. Migration 0043 adds
a `source` column (CHECK mirrors ExecutionSource/OutboxSourceKind,
DEFAULT 'http' backfills history); a shared `build_execution_log` helper
in executor-core; dispatcher logging for outbox triggers + queue
consumers (skips sync-HTTP rows the orchestrator already logs). `pic
logs` gains a source column + `--source` filter.
- G5: dev-only in-memory email capture under PICLOUD_DEV_MODE with no SMTP
(email::send succeeds locally), readable at GET /api/v1/admin/dev/emails
(Owner/Admin only; route mounted only in capture mode).
- G6: generalized the Rhai in-place-mutation footgun note (trim/replace/
make_upper/make_lower/crop/truncate/pad return ()).
- G2/G3/G4 (CLI): `pic members`, `pic files`, `pic queues`, read-only
`pic kv` (+ new kv_api.rs); `pic deploy --timeout/--memory/--kind/
--sandbox`; first-class `pic triggers create-{docs,files,pubsub,queue,
email}` wrappers. All new client path segments percent-encoded via seg().
H1 regression fix (found in re-review)
- The S6 change also runs in `compile_routes`, which compiles every stored
route at boot and on each route CRUD. A single stored route the new
validation rejects (creatable while the S6 gap existed) made the whole
compile Err and aborted startup. `compile_routes` is now lenient: it
skips an un-compilable row with a warning instead of bricking boot
(route creation still validates separately). Migration 0044 sweeps
pre-existing reserved-path routes on upgrade (WHERE mirrors
check_reserved exactly). Added regression tests for both.
Verified: cargo fmt, clippy --all-targets --all-features -D warnings, the
schema_snapshot test, and the new S6/lenient-compile unit tests all pass;
boot-resilience and G1/G5 confirmed live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
163 lines
5.1 KiB
Rust
163 lines
5.1 KiB
Rust
//! `/api/v1/admin/apps/{id}/kv*` — read-only KV inspection (G2).
|
|
//!
|
|
//! Mirrors the minimal `files_api` / `queues_api` admin surface so the
|
|
//! `pic kv` CLI (and a future dashboard tab) can browse stored keys
|
|
//! without a script. **Read-only by design** — KV writes go through
|
|
//! `kv::set` in scripts, which emit change events the trigger framework
|
|
//! depends on; an admin write would bypass that and could break app
|
|
//! invariants, so it is deliberately out of scope here (matching the
|
|
//! read-only queues precedent).
|
|
//!
|
|
//! Two operations:
|
|
//! * `GET /apps/{id}/kv?collection=<c>&cursor=&limit=` — list keys in a
|
|
//! collection (cursor-paginated).
|
|
//! * `GET /apps/{id}/kv/{collection}/{key}` — fetch one value.
|
|
//!
|
|
//! Capability: `AppKvRead`, resolved against the app loaded from the
|
|
//! path (same tier the SDK read path uses).
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::extract::{Path, Query, State};
|
|
use axum::response::{IntoResponse, Json, Response};
|
|
use axum::routing::get;
|
|
use axum::{Extension, Router};
|
|
use picloud_shared::{AppId, Principal};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::json;
|
|
|
|
use crate::app_repo::AppRepository;
|
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
|
use crate::kv_repo::KvRepo;
|
|
|
|
#[derive(Clone)]
|
|
pub struct KvAdminState {
|
|
pub kv: Arc<dyn KvRepo>,
|
|
pub apps: Arc<dyn AppRepository>,
|
|
pub authz: Arc<dyn AuthzRepo>,
|
|
}
|
|
|
|
pub fn kv_admin_router(state: KvAdminState) -> Router {
|
|
Router::new()
|
|
.route("/apps/{app_id}/kv", get(list_keys))
|
|
.route("/apps/{app_id}/kv/{collection}/{key}", get(get_value))
|
|
.with_state(state)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ListKvQuery {
|
|
pub collection: String,
|
|
#[serde(default)]
|
|
pub cursor: Option<String>,
|
|
#[serde(default)]
|
|
pub limit: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct GetValueResponse {
|
|
value: serde_json::Value,
|
|
}
|
|
|
|
/// Serialize mirror of `shared::KvListPage` (which is not `Serialize`).
|
|
#[derive(Debug, Serialize)]
|
|
struct ListKeysResponse {
|
|
keys: Vec<String>,
|
|
next_cursor: Option<String>,
|
|
}
|
|
|
|
async fn list_keys(
|
|
State(s): State<KvAdminState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
Query(q): Query<ListKvQuery>,
|
|
) -> Result<Json<ListKeysResponse>, KvApiError> {
|
|
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
|
require(s.authz.as_ref(), &principal, Capability::AppKvRead(app_id)).await?;
|
|
let page =
|
|
s.kv.list(
|
|
app_id,
|
|
&q.collection,
|
|
q.cursor.as_deref(),
|
|
q.limit.unwrap_or(0),
|
|
)
|
|
.await
|
|
.map_err(|e| KvApiError::Backend(e.to_string()))?;
|
|
Ok(Json(ListKeysResponse {
|
|
keys: page.keys,
|
|
next_cursor: page.next_cursor,
|
|
}))
|
|
}
|
|
|
|
async fn get_value(
|
|
State(s): State<KvAdminState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((id_or_slug, collection, key)): Path<(String, String, String)>,
|
|
) -> Result<Json<GetValueResponse>, KvApiError> {
|
|
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
|
require(s.authz.as_ref(), &principal, Capability::AppKvRead(app_id)).await?;
|
|
let value =
|
|
s.kv.get(app_id, &collection, &key)
|
|
.await
|
|
.map_err(|e| KvApiError::Backend(e.to_string()))?
|
|
.ok_or(KvApiError::NotFound)?;
|
|
Ok(Json(GetValueResponse { value }))
|
|
}
|
|
|
|
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, KvApiError> {
|
|
crate::app_repo::resolve_app(apps, ident)
|
|
.await
|
|
.map_err(|e| KvApiError::Backend(e.to_string()))?
|
|
.map(|l| l.app.id)
|
|
.ok_or(KvApiError::AppNotFound)
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum KvApiError {
|
|
#[error("app not found")]
|
|
AppNotFound,
|
|
#[error("key not found")]
|
|
NotFound,
|
|
#[error("forbidden")]
|
|
Forbidden,
|
|
#[error("authorization repo error: {0}")]
|
|
AuthzRepo(String),
|
|
#[error("kv backend: {0}")]
|
|
Backend(String),
|
|
}
|
|
|
|
impl From<AuthzDenied> for KvApiError {
|
|
fn from(d: AuthzDenied) -> Self {
|
|
match d {
|
|
AuthzDenied::Denied => Self::Forbidden,
|
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for KvApiError {
|
|
fn into_response(self) -> Response {
|
|
use axum::http::StatusCode;
|
|
let (status, body) = match &self {
|
|
Self::AppNotFound | Self::NotFound => {
|
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
|
}
|
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
|
Self::AuthzRepo(e) => {
|
|
tracing::error!(error = %e, "kv admin authz error");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
json!({ "error": "internal error" }),
|
|
)
|
|
}
|
|
Self::Backend(e) => {
|
|
tracing::error!(error = %e, "kv admin backend error");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
json!({ "error": "internal error" }),
|
|
)
|
|
}
|
|
};
|
|
(status, Json(body)).into_response()
|
|
}
|
|
}
|