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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
236
crates/manager-core/src/group_scripts_api.rs
Normal file
236
crates/manager-core/src/group_scripts_api.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -309,6 +309,33 @@ impl Client {
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/groups/{id_or_slug}/scripts` — a group's own
|
||||
/// scripts (Phase 4). Not inherited; just the group's rows.
|
||||
pub async fn group_scripts_list(&self, group_ident: &str) -> Result<Vec<Script>> {
|
||||
let g = seg(group_ident);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/groups/{g}/scripts"))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/groups/{id_or_slug}/scripts` — create a
|
||||
/// group-owned script (Phase 4).
|
||||
pub async fn group_scripts_create(
|
||||
&self,
|
||||
group_ident: &str,
|
||||
body: &CreateGroupScriptBody<'_>,
|
||||
) -> Result<Script> {
|
||||
let g = seg(group_ident);
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/groups/{g}/scripts"))
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
|
||||
/// uses PUT despite the field-level update semantics. `cfg` carries
|
||||
/// optional per-script runtime overrides (G3); unset fields are
|
||||
@@ -1637,6 +1664,24 @@ pub struct CreateScriptBody<'a> {
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
}
|
||||
|
||||
/// Phase 4: body for `POST /groups/{id}/scripts`. Like `CreateScriptBody`
|
||||
/// minus `app_id` — the owning group comes from the path.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateGroupScriptBody<'a> {
|
||||
pub name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
pub source: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_seconds: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub memory_limit_mb: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<ScriptKind>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UpdateScriptBody<'a> {
|
||||
source: &'a str,
|
||||
|
||||
@@ -8,16 +8,33 @@ use anyhow::{anyhow, Context, Result};
|
||||
use picloud_shared::AppId;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::{Client, CreateScriptBody, ScriptConfig};
|
||||
use crate::client::{Client, CreateGroupScriptBody, CreateScriptBody, ScriptConfig};
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]);
|
||||
|
||||
if let Some(group_ident) = group {
|
||||
// Phase 4: a group's own (non-inherited) scripts. The owner column
|
||||
// shows the group, not an app.
|
||||
let scripts = client.group_scripts_list(group_ident).await?;
|
||||
for s in scripts {
|
||||
table.row([
|
||||
s.id.to_string(),
|
||||
format!("group:{group_ident}"),
|
||||
s.name,
|
||||
s.version.to_string(),
|
||||
s.updated_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(ident) = app {
|
||||
let app = client.apps_get(ident).await?;
|
||||
let scripts = client.scripts_list_by_app(&app.app.slug).await?;
|
||||
@@ -64,7 +81,8 @@ pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
|
||||
pub async fn deploy(
|
||||
file: &Path,
|
||||
app_ident: &str,
|
||||
app_ident: Option<&str>,
|
||||
group_ident: Option<&str>,
|
||||
name_override: Option<&str>,
|
||||
description: Option<&str>,
|
||||
cfg: &ScriptConfig,
|
||||
@@ -89,28 +107,61 @@ pub async fn deploy(
|
||||
})?,
|
||||
};
|
||||
|
||||
// Slug-or-id resolution: a single GET satisfies both lookups and
|
||||
// gives us the canonical app_id needed for create.
|
||||
let app = client.apps_get(app_ident).await?;
|
||||
|
||||
let existing = client.scripts_list_by_app(app_ident).await?;
|
||||
let (script, action) = if let Some(s) = existing.into_iter().find(|s| s.name == name) {
|
||||
let updated = client
|
||||
.scripts_update_source(&s.id.to_string(), &source, cfg)
|
||||
.await?;
|
||||
(updated, "updated")
|
||||
} else {
|
||||
let body = CreateScriptBody {
|
||||
app_id: app.app.id,
|
||||
name: &name,
|
||||
description,
|
||||
source: &source,
|
||||
timeout_seconds: cfg.timeout_seconds,
|
||||
memory_limit_mb: cfg.memory_limit_mb,
|
||||
kind: cfg.kind,
|
||||
sandbox: cfg.sandbox,
|
||||
};
|
||||
(client.scripts_create(&body).await?, "created")
|
||||
// Deploy is create-or-update by name within the chosen owner. Exactly
|
||||
// one owner — clap marks `--group` as conflicting with `--app`, so this
|
||||
// only rejects the both-absent case.
|
||||
let (script, action) = match (app_ident, group_ident) {
|
||||
(Some(app_ident), None) => {
|
||||
// Slug-or-id resolution: a single GET gives the canonical app_id.
|
||||
let app = client.apps_get(app_ident).await?;
|
||||
let existing = client.scripts_list_by_app(app_ident).await?;
|
||||
if let Some(s) = existing.into_iter().find(|s| s.name == name) {
|
||||
let updated = client
|
||||
.scripts_update_source(&s.id.to_string(), &source, cfg)
|
||||
.await?;
|
||||
(updated, "updated")
|
||||
} else {
|
||||
let body = CreateScriptBody {
|
||||
app_id: app.app.id,
|
||||
name: &name,
|
||||
description,
|
||||
source: &source,
|
||||
timeout_seconds: cfg.timeout_seconds,
|
||||
memory_limit_mb: cfg.memory_limit_mb,
|
||||
kind: cfg.kind,
|
||||
sandbox: cfg.sandbox,
|
||||
};
|
||||
(client.scripts_create(&body).await?, "created")
|
||||
}
|
||||
}
|
||||
(None, Some(group_ident)) => {
|
||||
// Group scripts update through the owner-polymorphic by-id
|
||||
// endpoint; create through the group endpoint.
|
||||
let existing = client.group_scripts_list(group_ident).await?;
|
||||
if let Some(s) = existing.into_iter().find(|s| s.name == name) {
|
||||
let updated = client
|
||||
.scripts_update_source(&s.id.to_string(), &source, cfg)
|
||||
.await?;
|
||||
(updated, "updated")
|
||||
} else {
|
||||
let body = CreateGroupScriptBody {
|
||||
name: &name,
|
||||
description,
|
||||
source: &source,
|
||||
timeout_seconds: cfg.timeout_seconds,
|
||||
memory_limit_mb: cfg.memory_limit_mb,
|
||||
kind: cfg.kind,
|
||||
sandbox: cfg.sandbox,
|
||||
};
|
||||
(
|
||||
client.group_scripts_create(group_ident, &body).await?,
|
||||
"created",
|
||||
)
|
||||
}
|
||||
}
|
||||
(Some(_), Some(_)) | (None, None) => {
|
||||
return Err(anyhow!("deploy requires exactly one of --app or --group"));
|
||||
}
|
||||
};
|
||||
// Emit the script object so `--output json` callers can capture the
|
||||
// id for the follow-up routes/triggers calls.
|
||||
|
||||
@@ -529,15 +529,19 @@ enum DomainsCmd {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ScriptsCmd {
|
||||
/// List scripts. With `--app`, scoped to one app; without, one
|
||||
/// List scripts. With `--app`, scoped to one app; with `--group`, a
|
||||
/// group's own scripts (Phase 4); without either, one
|
||||
/// `GET /admin/scripts` for everything the caller can see.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
/// Phase 4: list a group's own (non-inherited) scripts.
|
||||
#[arg(long, conflicts_with = "app")]
|
||||
group: Option<String>,
|
||||
},
|
||||
|
||||
/// Upload a `.rhai` file. Patches the existing script with the
|
||||
/// matching name in `--app` if one exists, otherwise creates it.
|
||||
/// matching name in `--app`/`--group` if one exists, otherwise creates it.
|
||||
Deploy(DeployArgs),
|
||||
|
||||
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
|
||||
@@ -551,8 +555,14 @@ enum ScriptsCmd {
|
||||
#[derive(Args)]
|
||||
struct DeployArgs {
|
||||
file: PathBuf,
|
||||
/// Owning app (slug or id). Exactly one of `--app` / `--group`.
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
app: Option<String>,
|
||||
/// Phase 4: deploy a group-owned script (template inherited by
|
||||
/// descendant apps) instead of an app-owned one. Exactly one of
|
||||
/// `--app` / `--group`.
|
||||
#[arg(long, conflicts_with = "app")]
|
||||
group: Option<String>,
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
@@ -1465,15 +1475,16 @@ async fn main() -> ExitCode {
|
||||
},
|
||||
} => cmds::groups::members_rm(&group, &user_id).await,
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Ls { app },
|
||||
} => cmds::scripts::ls(app.as_deref(), mode).await,
|
||||
cmd: ScriptsCmd::Ls { app, group },
|
||||
} => cmds::scripts::ls(app.as_deref(), group.as_deref(), mode).await,
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Deploy(args),
|
||||
} => match args.script_config() {
|
||||
Ok(cfg) => {
|
||||
cmds::scripts::deploy(
|
||||
&args.file,
|
||||
&args.app,
|
||||
args.app.as_deref(),
|
||||
args.group.as_deref(),
|
||||
args.name.as_deref(),
|
||||
args.description.as_deref(),
|
||||
&cfg,
|
||||
@@ -1516,7 +1527,8 @@ async fn main() -> ExitCode {
|
||||
Ok(cfg) => {
|
||||
cmds::scripts::deploy(
|
||||
&args.file,
|
||||
&args.app,
|
||||
args.app.as_deref(),
|
||||
args.group.as_deref(),
|
||||
args.name.as_deref(),
|
||||
args.description.as_deref(),
|
||||
&cfg,
|
||||
|
||||
@@ -578,6 +578,13 @@ pub async fn build_app(
|
||||
groups: groups_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
};
|
||||
let group_scripts_state = picloud_manager_core::GroupScriptsState {
|
||||
scripts: script_repo.clone(),
|
||||
groups: groups_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
validator: engine.clone(),
|
||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||
};
|
||||
let config_state = picloud_manager_core::ConfigApiState {
|
||||
pool: pool.clone(),
|
||||
apps: apps_repo.clone(),
|
||||
@@ -606,6 +613,9 @@ pub async fn build_app(
|
||||
.merge(apps_router(apps_state))
|
||||
.merge(app_members_router(app_members_state))
|
||||
.merge(groups_router(groups_state))
|
||||
.merge(picloud_manager_core::group_scripts_router(
|
||||
group_scripts_state,
|
||||
))
|
||||
.merge(vars_router(vars_state))
|
||||
.merge(picloud_manager_core::config_router(config_state))
|
||||
.merge(picloud_manager_core::app_users_router(
|
||||
|
||||
@@ -86,7 +86,7 @@ pub use realtime::{BroadcasterError, NoopRealtimeBroadcaster, RealtimeBroadcaste
|
||||
pub use realtime_authority::{DenyAllRealtimeAuthority, RealtimeAuthority, SubscribeDenied};
|
||||
pub use route::{DispatchMode, HostKind, PathKind, Route};
|
||||
pub use sandbox::ScriptSandbox;
|
||||
pub use script::{Script, ScriptKind};
|
||||
pub use script::{Script, ScriptKind, ScriptOwner};
|
||||
pub use sdk_cx::SdkCallCx;
|
||||
pub use secrets::{
|
||||
validate_secret_name, NoopSecretsService, SecretsError, SecretsListPage, SecretsService,
|
||||
|
||||
Reference in New Issue
Block a user