feat(scripts): group-owned script admin API + pic scripts … --group

Phase 4-lite C2. Lets a group own scripts (templates inherited by descendant
apps), with the create/list/manage surface — but not yet the inherited
resolution (binding + runtime), which is C3.

Capabilities: add `GroupScriptsRead` (viewer+ on the group → script:read) and
`GroupScriptsWrite` (editor+ → script:write), mirroring the group-vars tier and
resolved through the same group-ancestor walk.

API:
  * New `group_scripts_api`: `POST/GET /groups/{id}/scripts` — create a
    group-owned endpoint script and list a group's own (non-inherited) rows.
    Phase 4-lite is endpoint-only and self-contained: `kind=module` and any
    `import` are rejected (group modules + the lexical resolver are Phase 4b).
    Owner resolved first (slug-or-uuid); capability bound to the resolved id.
  * The by-id `/scripts/{id}` get/update/delete/logs handlers are now
    owner-polymorphic via a `script_cap` helper: app-owned scripts gate on
    `App*` exactly as before; group-owned on `GroupScripts*`. This is what
    makes `deploy --group` idempotent (update reuses the by-id PUT) and lets a
    group script be deleted by id. (C1 had these fail closed for groups.)

Repo: `list_for_group(group_id)` (the group's own rows).

CLI: `pic scripts ls --group <g>`, `pic scripts deploy --group <g>` (create or
update by name; `--app`/`--group` are mutually exclusive, exactly one
required). `pic scripts delete <id>` already works for group scripts via the
owner-polymorphic by-id route.

Live-validated against the dev DB: create → update (v2 via the by-id PUT) →
list → delete, plus module rejection and the polymorphic row shape
(`app_id NULL`, `group_id` set). Group scripts still can't be routed/triggered
or invoked — that lands in C3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-25 20:10:20 +02:00
parent 48178c5f60
commit 43ed4e8e7f
13 changed files with 491 additions and 56 deletions

View File

@@ -12,8 +12,8 @@ use axum::{
Extension, Json, Router,
};
use picloud_shared::{
AppId, ExecutionLog, ExecutionSource, InstanceRole, Principal, Script, ScriptId, ScriptKind,
ScriptSandbox, ScriptValidator, ValidatedScript, ValidationError,
AppId, ExecutionLog, ExecutionSource, GroupId, InstanceRole, Principal, Script, ScriptId,
ScriptKind, ScriptOwner, ScriptSandbox, ScriptValidator, ValidatedScript, ValidationError,
};
use serde::Deserialize;
@@ -181,13 +181,25 @@ async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppI
Ok(lookup.app.id)
}
/// The owning app of a script, for app-scoped admin authz. Every script
/// before Phase 4 is app-owned and yields its app. A group-owned script
/// (Phase 4) is not addressable through the app-script admin API — it is
/// managed via the group-script API — so it 404s here, indistinguishable
/// from an absent id.
fn script_app(script: &Script) -> Result<AppId, ApiError> {
script.app_id.ok_or(ApiError::NotFound(script.id))
/// Capability authorizing an operation on a script of either owner (Phase 4).
/// App-owned scripts map to their `App*` capability exactly as before; a
/// group-owned script maps to the group-scoped capability, resolved against
/// the group ancestor walk. An orphan row (neither owner — impossible under
/// the DB CHECK) reads as a missing id.
///
/// The `app`/`group` arguments are the capability constructors for each
/// owner, so one helper serves read / write / admin / log-read by passing
/// the matching pair.
fn script_cap(
script: &Script,
app: fn(AppId) -> Capability,
group: fn(GroupId) -> Capability,
) -> Result<Capability, ApiError> {
match script.owner() {
Some(ScriptOwner::App(a)) => Ok(app(a)),
Some(ScriptOwner::Group(g)) => Ok(group(g)),
None => Err(ApiError::NotFound(script.id)),
}
}
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
@@ -199,7 +211,7 @@ async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(script_app(&script)?),
script_cap(&script, Capability::AppRead, Capability::GroupScriptsRead)?,
)
.await?;
Ok(Json(script))
@@ -301,7 +313,11 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteScript(script_app(&script)?),
script_cap(
&script,
Capability::AppWriteScript,
Capability::GroupScriptsWrite,
)?,
)
.await?;
@@ -371,13 +387,15 @@ async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
Path(id): Path<ScriptId>,
) -> Result<StatusCode, ApiError> {
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
// Delete is gated tighter than Save: editors can edit scripts but
// only app_admin / instance admin / owner can remove them. See
// blueprint §11.6.
// Delete is gated tighter than Save for app-owned scripts: editors can
// edit but only app_admin / instance admin / owner can remove them (§11.6).
// A group-owned script (Phase 4) has no group-admin-tier script cap, so it
// is gated at `GroupScriptsWrite` (editor+ on the group) — the same tier
// that created it; group deletion itself stays group_admin-gated elsewhere.
require(
state.authz.as_ref(),
&principal,
Capability::AppAdmin(script_app(&script)?),
script_cap(&script, Capability::AppAdmin, Capability::GroupScriptsWrite)?,
)
.await?;
state.repo.delete(id).await?;
@@ -418,7 +436,11 @@ async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
require(
state.authz.as_ref(),
&principal,
Capability::AppLogRead(script_app(&script)?),
script_cap(
&script,
Capability::AppLogRead,
Capability::GroupScriptsRead,
)?,
)
.await?;
// Cap to keep the dashboard responsive; the data plane writes are

View File

@@ -135,6 +135,13 @@ pub enum Capability {
/// Write (set/delete) a group-owned secret (Phase 3). editor+ on the
/// group.
GroupSecretsWrite(GroupId),
/// Read (get / list) group-owned scripts (Phase 4). Resolved via the
/// group ancestor walk; viewer+ on the group. Maps to `script:read`.
GroupScriptsRead(GroupId),
/// Create / update / delete a group-owned script (Phase 4). editor+ on
/// the group; maps to `script:write` — the same tier as `AppWriteScript`
/// for an app, lifted to the group owner.
GroupScriptsWrite(GroupId),
/// Send an outbound email from a script in this app (v1.1.7). Maps
/// to `script:write` on API keys (sending mail is an outbound
/// side-effect like an HTTP request). Granted to `editor`+.
@@ -199,7 +206,9 @@ impl Capability {
| Self::GroupVarsRead(_)
| Self::GroupVarsWrite(_)
| Self::GroupSecretsRead(_)
| Self::GroupSecretsWrite(_) => None,
| Self::GroupSecretsWrite(_)
| Self::GroupScriptsRead(_)
| Self::GroupScriptsWrite(_) => None,
Self::AppRead(id)
| Self::AppWriteScript(id)
| Self::AppWriteRoute(id)
@@ -250,7 +259,8 @@ impl Capability {
| Self::AppUsersRead(_)
| Self::AppVarsRead(_)
| Self::GroupRead(_)
| Self::GroupVarsRead(_) => Scope::ScriptRead,
| Self::GroupVarsRead(_)
| Self::GroupScriptsRead(_) => Scope::ScriptRead,
Self::AppWriteScript(_)
| Self::AppKvWrite(_)
| Self::AppDocsWrite(_)
@@ -268,6 +278,7 @@ impl Capability {
// not app:admin. Only the VALUE READ (GroupSecretsRead) sits at
// the admin tier below.
| Self::GroupSecretsWrite(_)
| Self::GroupScriptsWrite(_)
| Self::AppInvoke(_) => Scope::ScriptWrite,
Self::AppWriteRoute(_) => Scope::RouteWrite,
Self::AppManageDomains(_) => Scope::DomainManage,
@@ -453,7 +464,9 @@ async fn role_grants(
| Capability::GroupVarsRead(g)
| Capability::GroupVarsWrite(g)
| Capability::GroupSecretsRead(g)
| Capability::GroupSecretsWrite(g) => {
| Capability::GroupSecretsWrite(g)
| Capability::GroupScriptsRead(g)
| Capability::GroupScriptsWrite(g) => {
group_member_grants(repo, principal.user_id, cap, g).await
}
// Creating a root-level group is an instance act — members
@@ -513,12 +526,15 @@ async fn group_member_grants(
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
match cap {
// viewer+ reads group metadata and config vars.
Capability::GroupRead(_) | Capability::GroupVarsRead(_) => true,
// editor+ writes config vars/secrets.
// viewer+ reads group metadata, config vars, and scripts.
Capability::GroupRead(_)
| Capability::GroupVarsRead(_)
| Capability::GroupScriptsRead(_) => true,
// editor+ writes config vars/secrets/scripts.
Capability::GroupWrite(_)
| Capability::GroupVarsWrite(_)
| Capability::GroupSecretsWrite(_) => {
| Capability::GroupSecretsWrite(_)
| Capability::GroupScriptsWrite(_) => {
matches!(role, AppRole::Editor | AppRole::AppAdmin)
}
// group_admin manages the group + reads secret VALUES (the

View File

@@ -1658,6 +1658,12 @@ mod tests {
) -> Result<Vec<Script>, ScriptRepositoryError> {
unimplemented!("not used by this test")
}
async fn list_for_group(
&self,
_group_id: picloud_shared::GroupId,
) -> Result<Vec<Script>, ScriptRepositoryError> {
unimplemented!("not used by this test")
}
async fn list_for_user(
&self,
_user_id: AdminUserId,

View File

@@ -0,0 +1,236 @@
//! `/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 4-lite: endpoint-only, self-contained.
if input.kind == ScriptKind::Module {
return Err(GroupScriptsApiError::Invalid(
"group modules are not supported yet (Phase 4b); create an endpoint script".into(),
));
}
let validated = s.validator.validate(&input.source)?;
if !validated.imports.is_empty() {
return Err(GroupScriptsApiError::Invalid(format!(
"group scripts must be self-contained in Phase 4-lite; \
remove the import(s): {}",
validated.imports.join(", ")
)));
}
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: ScriptKind::Endpoint,
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,
// Self-contained — no import edges (enforced above).
imports: Vec::new(),
})
.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()
}
}

View File

@@ -246,6 +246,12 @@ mod tests {
async fn list_for_app(&self, _app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
unimplemented!()
}
async fn list_for_group(
&self,
_group_id: picloud_shared::GroupId,
) -> Result<Vec<Script>, ScriptRepositoryError> {
unimplemented!()
}
async fn list_for_user(
&self,
_user_id: AdminUserId,

View File

@@ -51,6 +51,7 @@ pub mod files_sweep;
pub mod gc;
pub mod group_members_repo;
pub mod group_repo;
pub mod group_scripts_api;
pub mod groups_api;
pub mod http_service;
pub mod invoke_service;
@@ -182,6 +183,7 @@ pub use group_repo::{
GroupChildCounts, GroupRepository, GroupRepositoryError, PostgresGroupRepository,
ROOT_GROUP_SLUG,
};
pub use group_scripts_api::{group_scripts_router, GroupScriptsApiError, GroupScriptsState};
pub use groups_api::{groups_router, GroupsApiError, GroupsState};
pub use http_service::{HttpConfig, HttpServiceImpl};
pub use kv_api::{kv_admin_router, KvAdminState};

View File

@@ -38,6 +38,10 @@ pub trait ScriptRepository: Send + Sync {
/// "global" views; the dashboard reaches scripts via `list_for_app`.
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
/// Phase 4: every script owned directly by `group_id` (not inherited —
/// just the group's own rows). Backs the group-script admin list.
async fn list_for_group(&self, group_id: GroupId)
-> Result<Vec<Script>, ScriptRepositoryError>;
/// Every script in any app the user is a member of. Drives
/// `GET /admin/scripts` for `member` instance-role callers so the
/// API never returns scripts they shouldn't see — even before the
@@ -99,6 +103,12 @@ impl<T: ScriptRepository + ?Sized> ScriptRepository for std::sync::Arc<T> {
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
(**self).list_for_app(app_id).await
}
async fn list_for_group(
&self,
group_id: GroupId,
) -> Result<Vec<Script>, ScriptRepositoryError> {
(**self).list_for_group(group_id).await
}
async fn list_for_user(
&self,
user_id: AdminUserId,
@@ -262,6 +272,19 @@ impl ScriptRepository for PostgresScriptRepository {
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_group(
&self,
group_id: GroupId,
) -> Result<Vec<Script>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
"SELECT {SCRIPT_SELECT_COLS} FROM scripts WHERE group_id = $1 ORDER BY name"
))
.bind(group_id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_user(
&self,
user_id: AdminUserId,

View File

@@ -1411,6 +1411,12 @@ mod tests {
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
unimplemented!()
}
async fn list_for_group(
&self,
_group_id: picloud_shared::GroupId,
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
unimplemented!()
}
async fn list_for_user(
&self,
_user_id: AdminUserId,