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
|
||||
|
||||
Reference in New Issue
Block a user