fix(cms-poc): remediate findings surfaced by the CMS proof-of-concept
Additive fixes surfaced by building a full CMS on PiCloud
(examples/cms-poc/). One migration (0077_apps_cors.sql); no destructive
changes.
Security:
- Close the 502 info-leak on user routes: an uncaught script error (incl.
a throw) leaked the app UUID + script fn names + source line/col. The
user-route (inbox) path now shares scrub_runtime_detail with the
execute-by-id path — raw detail is logged under a correlation id and the
client sees only "script execution error (ref: <uuid>)".
Added:
- docs::find $contains operator (array membership via JSONB @>), per-app
and group-shared — the inverse of $in.
- App-user role management from API/CLI: pic users add-role / rm-role,
backed by the apps/{id}/users/{user_id}/roles endpoints (AppUsersAdmin).
- Per-app CORS: pic apps cors set, apps.cors_allowed_origins; the
orchestrator echoes an allowed Origin and answers OPTIONS preflight.
- Non-JSON request bodies: form-urlencoded -> object, text/* -> string,
other -> base64; ctx.request.content_type added. Malformed JSON still 400s.
- Binary responses via #{ body_base64 } with a script-set Content-Type.
- workflow::run_status(run_id) SDK (F-038): the starting script can poll a
run — #{ status, output, error, steps } or () when no such run belongs to
the app (app_id is the isolation boundary); same AppInvoke gate as start.
Fixed:
- pic apply "app not found" is now actionable (points at pic apps create).
- Interceptor- (F-021) and workflow-step-bound (F-037) scripts no longer
warn "no route or trigger" at plan time — both count as reachability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -954,6 +954,28 @@ impl Client {
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/cors` (F-030)
|
||||
pub async fn app_cors_get(&self, app: &str) -> Result<CorsConfigDto> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/cors"))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `PUT /api/v1/admin/apps/{id_or_slug}/cors` (F-030)
|
||||
pub async fn app_cors_set(&self, app: &str, origins: Vec<String>) -> Result<CorsConfigDto> {
|
||||
let app = seg(app);
|
||||
let body = serde_json::json!({ "allowed_origins": origins });
|
||||
let resp = self
|
||||
.request(Method::PUT, &format!("/api/v1/admin/apps/{app}/cors"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
// ---------- topics ----------
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/topics`
|
||||
@@ -1092,6 +1114,39 @@ impl Client {
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id_or_slug}/users/{user_id}/roles` (F-012)
|
||||
pub async fn app_user_add_role(&self, app: &str, user_id: &str, role: &str) -> Result<AppUser> {
|
||||
let (app, user_id) = (seg(app), seg(user_id));
|
||||
let body = serde_json::json!({ "role": role });
|
||||
let resp = self
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!("/api/v1/admin/apps/{app}/users/{user_id}/roles"),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/apps/{id_or_slug}/users/{user_id}/roles/{role}` (F-012)
|
||||
pub async fn app_user_remove_role(
|
||||
&self,
|
||||
app: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<AppUser> {
|
||||
let (app, user_id, role) = (seg(app), seg(user_id), seg(role));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/admin/apps/{app}/users/{user_id}/roles/{role}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
// --- App membership (G2) ----------------------------------------------
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/members`
|
||||
@@ -2204,6 +2259,12 @@ pub struct RevokeSessionsResponseDto {
|
||||
pub revoked: u64,
|
||||
}
|
||||
|
||||
/// F-030 per-app CORS config (mirrors `apps_api::CorsConfig`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CorsConfigDto {
|
||||
pub allowed_origins: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeadLetterCountDto {
|
||||
pub unresolved: i64,
|
||||
|
||||
41
crates/picloud-cli/src/cmds/apps_cors.rs
Normal file
41
crates/picloud-cli/src/cmds/apps_cors.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! `pic apps cors {ls,set}` — manage an app's CORS allow-list (F-030).
|
||||
//!
|
||||
//! A browser SPA served from a different origin (e.g. a Vite dev server on
|
||||
//! `:5173`) can't call an app's user routes unless the app allows that origin.
|
||||
//! `set` replaces the whole list; `*` allows any origin; an empty `set`
|
||||
//! disables CORS. This is the CLI surface over the `apps/{id}/cors` admin API.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
fn print_cors(origins: &[String], mode: OutputMode) {
|
||||
let mut block = KvBlock::new();
|
||||
block.field(
|
||||
"allowed_origins",
|
||||
if origins.is_empty() {
|
||||
"- (CORS disabled)".into()
|
||||
} else {
|
||||
origins.join(", ")
|
||||
},
|
||||
);
|
||||
block.print(mode);
|
||||
}
|
||||
|
||||
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let cfg = client.app_cors_get(app).await?;
|
||||
print_cors(&cfg.allowed_origins, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set(app: &str, origins: Vec<String>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let cfg = client.app_cors_set(app, origins).await?;
|
||||
print_cors(&cfg.allowed_origins, mode);
|
||||
Ok(())
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod admins;
|
||||
pub mod api_keys;
|
||||
pub mod apply;
|
||||
pub mod apps;
|
||||
pub mod apps_cors;
|
||||
pub mod apps_domains;
|
||||
pub mod collections;
|
||||
pub mod config;
|
||||
|
||||
@@ -1,41 +1,21 @@
|
||||
//! `pic users {ls,show,reset-password,revoke-sessions}` — admin view of
|
||||
//! an app's *end-users* (people who registered via `users::create` in a
|
||||
//! script), distinct from `pic admins` (instance control-plane accounts)
|
||||
//! and app membership/collaborators.
|
||||
//! `pic users {ls,show,reset-password,revoke-sessions,add-role,rm-role}` —
|
||||
//! admin view of an app's *end-users* (people who registered via
|
||||
//! `users::create` in a script), distinct from `pic admins` (instance
|
||||
//! control-plane accounts) and app membership/collaborators.
|
||||
//!
|
||||
//! Scoped to read + the two admin actions the E2E report needed. Create,
|
||||
//! Scoped to read + the admin actions the E2E report needed. Create,
|
||||
//! delete, and invitations are deferred to a follow-up.
|
||||
|
||||
use anyhow::Result;
|
||||
use picloud_shared::AppUser;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let users = client.app_users_list(app, limit).await?;
|
||||
let mut table = Table::new(["id", "email", "display_name", "last_login_at", "created_at"]);
|
||||
for u in users {
|
||||
table.row([
|
||||
u.id.to_string(),
|
||||
u.email.clone(),
|
||||
u.display_name.clone().unwrap_or_else(|| "-".into()),
|
||||
u.last_login_at
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
u.created_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn show(app: &str, user_id: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let u = client.app_user_get(app, user_id).await?;
|
||||
/// Render an end-user's identity + role set as a key/value block. Shared by
|
||||
/// `show`, `add-role`, and `rm-role` so the role mutations echo the result.
|
||||
fn print_user(u: &AppUser, mode: OutputMode) {
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("id", u.id.to_string())
|
||||
@@ -67,6 +47,52 @@ pub async fn show(app: &str, user_id: &str, mode: OutputMode) -> Result<()> {
|
||||
.field("created_at", u.created_at.to_rfc3339())
|
||||
.field("updated_at", u.updated_at.to_rfc3339());
|
||||
block.print(mode);
|
||||
}
|
||||
|
||||
pub async fn ls(app: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let users = client.app_users_list(app, limit).await?;
|
||||
let mut table = Table::new(["id", "email", "display_name", "last_login_at", "created_at"]);
|
||||
for u in users {
|
||||
table.row([
|
||||
u.id.to_string(),
|
||||
u.email.clone(),
|
||||
u.display_name.clone().unwrap_or_else(|| "-".into()),
|
||||
u.last_login_at
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
u.created_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn show(app: &str, user_id: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let u = client.app_user_get(app, user_id).await?;
|
||||
print_user(&u, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Grant an end-user a role (F-012) — the operator equivalent of the
|
||||
/// script-only `users::add_role`. Echoes the refreshed user.
|
||||
pub async fn add_role(app: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let u = client.app_user_add_role(app, user_id, role).await?;
|
||||
print_user(&u, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke a role from an end-user (F-012). Echoes the refreshed user.
|
||||
pub async fn rm_role(app: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let u = client.app_user_remove_role(app, user_id, role).await?;
|
||||
print_user(&u, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -567,6 +567,26 @@ enum AppsCmd {
|
||||
#[command(subcommand)]
|
||||
cmd: DomainsCmd,
|
||||
},
|
||||
|
||||
/// Manage an app's CORS allow-list so a browser SPA on another origin
|
||||
/// can call its user routes.
|
||||
Cors {
|
||||
#[command(subcommand)]
|
||||
cmd: CorsCmd,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum CorsCmd {
|
||||
/// Show the app's allowed CORS origins.
|
||||
Ls { app: String },
|
||||
/// Replace the app's allowed CORS origins (space-separated). Pass `*`
|
||||
/// to allow any origin, or no origins to disable CORS.
|
||||
Set {
|
||||
app: String,
|
||||
/// Allowed origins, e.g. `http://localhost:5173 https://app.example.com`.
|
||||
origins: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -1327,6 +1347,24 @@ enum UsersCmd {
|
||||
app: String,
|
||||
user_id: String,
|
||||
},
|
||||
|
||||
/// Grant an end-user a role (e.g. `admin`, `author`). Idempotent.
|
||||
#[command(name = "add-role")]
|
||||
AddRole {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
user_id: String,
|
||||
role: String,
|
||||
},
|
||||
|
||||
/// Revoke a role from an end-user. Idempotent.
|
||||
#[command(name = "rm-role")]
|
||||
RmRole {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
user_id: String,
|
||||
role: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -1663,6 +1701,16 @@ async fn main() -> ExitCode {
|
||||
cmd: DomainsCmd::Rm { app, domain_id },
|
||||
},
|
||||
} => cmds::apps_domains::rm(&app, &domain_id).await,
|
||||
Cmd::Apps {
|
||||
cmd: AppsCmd::Cors {
|
||||
cmd: CorsCmd::Ls { app },
|
||||
},
|
||||
} => cmds::apps_cors::ls(&app, mode).await,
|
||||
Cmd::Apps {
|
||||
cmd: AppsCmd::Cors {
|
||||
cmd: CorsCmd::Set { app, origins },
|
||||
},
|
||||
} => cmds::apps_cors::set(&app, origins, mode).await,
|
||||
Cmd::Projects {
|
||||
cmd: ProjectsCmd::Ls,
|
||||
} => cmds::projects::ls(mode).await,
|
||||
@@ -2119,6 +2167,12 @@ async fn main() -> ExitCode {
|
||||
Cmd::Users {
|
||||
cmd: UsersCmd::RevokeSessions { app, user_id },
|
||||
} => cmds::users::revoke_sessions(&app, &user_id, mode).await,
|
||||
Cmd::Users {
|
||||
cmd: UsersCmd::AddRole { app, user_id, role },
|
||||
} => cmds::users::add_role(&app, &user_id, &role, mode).await,
|
||||
Cmd::Users {
|
||||
cmd: UsersCmd::RmRole { app, user_id, role },
|
||||
} => cmds::users::rm_role(&app, &user_id, &role, mode).await,
|
||||
Cmd::DeadLetters {
|
||||
cmd: DeadLettersCmd::Count { app },
|
||||
} => cmds::dead_letters::count(&app, mode).await,
|
||||
|
||||
Reference in New Issue
Block a user