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>
241 lines
8.3 KiB
Rust
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()
|
|
}
|
|
}
|