From 43ed4e8e7f936c924462e37819fd43265ce40a1f Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 25 Jun 2026 20:10:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(scripts):=20group-owned=20script=20admin?= =?UTF-8?q?=20API=20+=20`pic=20scripts=20=E2=80=A6=20--group`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `, `pic scripts deploy --group ` (create or update by name; `--app`/`--group` are mutually exclusive, exactly one required). `pic scripts delete ` 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) --- crates/manager-core/src/api.rs | 54 +++-- crates/manager-core/src/authz.rs | 30 ++- crates/manager-core/src/dispatcher.rs | 6 + crates/manager-core/src/group_scripts_api.rs | 236 +++++++++++++++++++ crates/manager-core/src/invoke_service.rs | 6 + crates/manager-core/src/lib.rs | 2 + crates/manager-core/src/repo.rs | 23 ++ crates/manager-core/src/triggers_api.rs | 6 + crates/picloud-cli/src/client.rs | 45 ++++ crates/picloud-cli/src/cmds/scripts.rs | 101 ++++++-- crates/picloud-cli/src/main.rs | 26 +- crates/picloud/src/lib.rs | 10 + crates/shared/src/lib.rs | 2 +- 13 files changed, 491 insertions(+), 56 deletions(-) create mode 100644 crates/manager-core/src/group_scripts_api.rs diff --git a/crates/manager-core/src/api.rs b/crates/manager-core/src/api.rs index 067c00b..c324a2f 100644 --- a/crates/manager-core/src/api.rs +++ b/crates/manager-core/src/api.rs @@ -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 Result { - 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 { + 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( @@ -199,7 +211,7 @@ async fn get_script( 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( 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( Path(id): Path, ) -> Result { 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( 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 diff --git a/crates/manager-core/src/authz.rs b/crates/manager-core/src/authz.rs index df554d4..9f27ccc 100644 --- a/crates/manager-core/src/authz.rs +++ b/crates/manager-core/src/authz.rs @@ -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 diff --git a/crates/manager-core/src/dispatcher.rs b/crates/manager-core/src/dispatcher.rs index ee64839..e6975a1 100644 --- a/crates/manager-core/src/dispatcher.rs +++ b/crates/manager-core/src/dispatcher.rs @@ -1658,6 +1658,12 @@ mod tests { ) -> Result, ScriptRepositoryError> { unimplemented!("not used by this test") } + async fn list_for_group( + &self, + _group_id: picloud_shared::GroupId, + ) -> Result, ScriptRepositoryError> { + unimplemented!("not used by this test") + } async fn list_for_user( &self, _user_id: AdminUserId, diff --git a/crates/manager-core/src/group_scripts_api.rs b/crates/manager-core/src/group_scripts_api.rs new file mode 100644 index 0000000..2394eb4 --- /dev/null +++ b/crates/manager-core/src/group_scripts_api.rs @@ -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, + pub groups: Arc, + pub authz: Arc, + pub validator: Arc, + 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, + 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, + pub memory_limit_mb: Option, + #[serde(default)] + pub sandbox: ScriptSandbox, +} + +async fn list_group_scripts( + State(s): State, + Extension(principal): Extension, + Path(id_or_slug): Path, +) -> Result>, 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, + Extension(principal): Extension, + Path(id_or_slug): Path, + Json(input): Json, +) -> Result<(StatusCode, Json