Files
PiCloud/crates/manager-core/src/group_scripts_api.rs
MechaCat02 4e25a142bd fix(modules): validate group module create per kind (Phase 4b review)
Adversarial-review finding: `create_group_script` validated every source
with `validate()` regardless of kind, so an imperatively-created group
module (`pic scripts deploy --group g --name kv --kind module`) skipped the
two gates every other module-write path enforces (app create/update in
api.rs, declarative apply in apply_service): the stricter `validate_module`
shape check and the `RESERVED_MODULE_NAMES` guard. A malformed-shape group
module was accepted at create (only failing later at import-resolve time)
and a reserved name (`kv`, `log`, …) slipped through.

Branch on kind to mirror the app path. Adds a journey asserting a reserved
group-module name is rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:32:34 +02:00

241 lines
8.3 KiB
Rust

//! `/api/v1/admin/groups/{id}/scripts*` — group-owned script admin (Phase 4).
//!
//! * `GET /groups/{id}/scripts` — list the group's own scripts (not
//! inherited; just rows owned directly by this group). Gated by
//! `GroupScriptsRead` (viewer+ on the group).
//! * `POST /groups/{id}/scripts` — create a group-owned script. Gated by
//! `GroupScriptsWrite` (editor+ on the group).
//!
//! A group script is a **template** inherited by every descendant app,
//! resolved by name with nearest-owner-wins (CoW). Get / update / delete of an
//! existing group script go through the by-id `/scripts/{id}` endpoints, which
//! are owner-polymorphic — a group script there gates on `GroupScripts*`.
//!
//! **Phase 4-lite scope:** group ENDPOINT scripts only, and they must be
//! self-contained — `kind=module` and any `import` are rejected here, because
//! the origin-aware (lexical) module resolver is Phase 4b. The owner is
//! resolved FIRST (slug-or-uuid) and `authz::require` binds the capability to
//! the resolved group id, never to a caller-controlled path param.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use picloud_shared::{
GroupId, Principal, Script, ScriptKind, ScriptSandbox, ScriptValidator, ValidationError,
};
use serde::Deserialize;
use serde_json::json;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::group_repo::GroupRepository;
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
use crate::sandbox::SandboxCeiling;
#[derive(Clone)]
pub struct GroupScriptsState {
pub scripts: Arc<dyn ScriptRepository>,
pub groups: Arc<dyn GroupRepository>,
pub authz: Arc<dyn AuthzRepo>,
pub validator: Arc<dyn ScriptValidator>,
pub sandbox_ceiling: SandboxCeiling,
}
pub fn group_scripts_router(state: GroupScriptsState) -> Router {
Router::new()
.route(
"/groups/{group_id}/scripts",
get(list_group_scripts).post(create_group_script),
)
.with_state(state)
}
#[derive(Debug, Deserialize)]
pub struct CreateGroupScriptRequest {
pub name: String,
pub description: Option<String>,
pub source: String,
/// Phase 4-lite accepts only `endpoint`. A `module` is rejected (group
/// modules + the lexical import resolver are Phase 4b).
#[serde(default)]
pub kind: ScriptKind,
pub timeout_seconds: Option<i32>,
pub memory_limit_mb: Option<i32>,
#[serde(default)]
pub sandbox: ScriptSandbox,
}
async fn list_group_scripts(
State(s): State<GroupScriptsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<Script>>, GroupScriptsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupScriptsRead(group_id),
)
.await?;
Ok(Json(s.scripts.list_for_group(group_id).await?))
}
async fn create_group_script(
State(s): State<GroupScriptsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<CreateGroupScriptRequest>,
) -> Result<(StatusCode, Json<Script>), GroupScriptsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupScriptsWrite(group_id),
)
.await?;
// Phase 4b: group modules + imports are allowed. Validate per kind,
// mirroring the app-script create path (`api.rs`): a module gets the
// stricter shape check + the reserved-name guard; an endpoint the
// parse-only path. Without the kind branch a malformed-shape group
// module would be accepted here and only fail later at import time,
// and a reserved name (`kv`, `log`, …) would slip through. Imports
// resolve lexically from this group's chain at runtime (§5.5); the
// recorded edges feed the declarative dangling-import plan check.
let validated = if input.kind == ScriptKind::Module {
if crate::api::RESERVED_MODULE_NAMES.contains(&input.name.as_str()) {
return Err(GroupScriptsApiError::Invalid(format!(
"{:?} is a reserved module name (shadows a built-in SDK namespace)",
input.name
)));
}
s.validator.validate_module(&input.source)?
} else {
s.validator.validate(&input.source)?
};
s.sandbox_ceiling
.check(&input.sandbox)
.map_err(|e| GroupScriptsApiError::Invalid(e.to_string()))?;
let created = s
.scripts
.create(NewScript {
app_id: None,
group_id: Some(group_id),
name: input.name,
description: input.description,
source: input.source,
kind: input.kind,
timeout_seconds: input.timeout_seconds,
memory_limit_mb: input.memory_limit_mb,
sandbox: if input.sandbox.is_empty() {
None
} else {
Some(input.sandbox)
},
enabled: true,
imports: validated.imports,
})
.await?;
Ok((StatusCode::CREATED, Json(created)))
}
async fn resolve_group(
groups: &dyn GroupRepository,
ident: &str,
) -> Result<GroupId, GroupScriptsApiError> {
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
groups
.get_by_id(uuid.into())
.await
.map_err(|e| GroupScriptsApiError::Backend(e.to_string()))?
} else {
groups
.get_by_slug(ident)
.await
.map_err(|e| GroupScriptsApiError::Backend(e.to_string()))?
};
found
.map(|g| g.id)
.ok_or(GroupScriptsApiError::GroupNotFound)
}
#[derive(Debug, thiserror::Error)]
pub enum GroupScriptsApiError {
#[error("group not found")]
GroupNotFound,
#[error("invalid request: {0}")]
Invalid(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("scripts backend: {0}")]
Backend(String),
}
impl From<AuthzDenied> for GroupScriptsApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl From<AuthzError> for GroupScriptsApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl From<ValidationError> for GroupScriptsApiError {
fn from(e: ValidationError) -> Self {
Self::Invalid(e.to_string())
}
}
impl From<ScriptRepositoryError> for GroupScriptsApiError {
fn from(e: ScriptRepositoryError) -> Self {
match e {
ScriptRepositoryError::Conflict(m) => Self::Conflict(m),
ScriptRepositoryError::NotFound(id) => Self::Invalid(format!("script {id} not found")),
ScriptRepositoryError::Db(e) => Self::Backend(e.to_string()),
}
}
}
impl IntoResponse for GroupScriptsApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::GroupNotFound => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
Self::Invalid(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::Conflict(_) => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "group-scripts admin authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Backend(e) => {
tracing::error!(error = %e, "group-scripts admin backend error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}