Compare commits
26 Commits
feat/proje
...
feat/group
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49c4fb41ce | ||
|
|
8725939172 | ||
|
|
eea1d8984e | ||
|
|
a432091191 | ||
|
|
2b27012f56 | ||
|
|
c900ca5bbf | ||
|
|
695987d6b7 | ||
|
|
d4b5632db1 | ||
|
|
345f265062 | ||
|
|
aa3995ae05 | ||
|
|
a27863d4a6 | ||
|
|
b3f05dfe2a | ||
|
|
5e62f4acfe | ||
|
|
73c8c289c1 | ||
|
|
816f143ffd | ||
|
|
2ba476aac8 | ||
|
|
79153b2063 | ||
|
|
9e1c24f729 | ||
|
|
8c805a07d0 | ||
|
|
bfab7c781d | ||
|
|
4223d3c320 | ||
|
|
55cf995eda | ||
|
|
627996cde7 | ||
|
|
be5df06a48 | ||
|
|
b8a4f30219 | ||
|
|
d9b3e9973c |
@@ -0,0 +1,8 @@
|
||||
-- Three-state `enabled` lifecycle (blueprint §4.3), scripts + routes half.
|
||||
-- Triggers already carry `enabled` (0008) honored at match/schedule time;
|
||||
-- this adds the same toggle to scripts and routes. A disabled script is not
|
||||
-- invocable; a disabled route 404s (indistinguishable from absent). Default
|
||||
-- TRUE so every existing row stays active — no behavior change on migrate.
|
||||
|
||||
ALTER TABLE scripts ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
ALTER TABLE routes ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
29
crates/manager-core/migrations/0046_trigger_name.sql
Normal file
29
crates/manager-core/migrations/0046_trigger_name.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- Trigger `name` (§4.5): an explicit per-app identifier that becomes the
|
||||
-- manifest merge/upsert key (Step B switches the apply diff to key on it).
|
||||
-- Until now triggers were identified only by their per-kind semantic tuple,
|
||||
-- so the declarative tool could only Create/Delete them, never Update.
|
||||
--
|
||||
-- Backfill existing rows with `{kind}-{n}` (n = per-(app,kind) sequence by
|
||||
-- creation order) — the spec-sanctioned fallback when there's no cleaner
|
||||
-- entity token. Then enforce NOT NULL + UNIQUE(app_id, name).
|
||||
|
||||
-- A `gen_random_uuid()` default keeps existing INSERTs (which don't yet
|
||||
-- supply a name) valid and unique — the manager-core write paths start
|
||||
-- providing meaningful names in the follow-up; new rows until then get a
|
||||
-- harmless unique placeholder.
|
||||
ALTER TABLE triggers
|
||||
ADD COLUMN name TEXT NOT NULL DEFAULT gen_random_uuid()::text;
|
||||
|
||||
-- Rewrite the just-defaulted existing rows to the readable `{kind}-{n}` form.
|
||||
UPDATE triggers t
|
||||
SET name = sub.nm
|
||||
FROM (
|
||||
SELECT id,
|
||||
kind || '-' || row_number() OVER (
|
||||
PARTITION BY app_id, kind ORDER BY created_at, id
|
||||
) AS nm
|
||||
FROM triggers
|
||||
) sub
|
||||
WHERE t.id = sub.id;
|
||||
|
||||
CREATE UNIQUE INDEX triggers_app_name_uniq ON triggers (app_id, name);
|
||||
82
crates/manager-core/migrations/0047_groups.sql
Normal file
82
crates/manager-core/migrations/0047_groups.sql
Normal file
@@ -0,0 +1,82 @@
|
||||
-- Phase 2: groups as a pure org / RBAC / UI container — see
|
||||
-- docs/design/groups-and-project-tool.md §5, §9.
|
||||
--
|
||||
-- Groups form a GitLab-like, single-parent tree ABOVE apps. Phase 2 adds
|
||||
-- the tree, hierarchy-aware membership, and structural-mutation safety —
|
||||
-- but NO group-owned resources yet (scripts/vars/secrets stay app-owned;
|
||||
-- that is Phase 3). The only data-plane touch is apps gaining a parent
|
||||
-- pointer.
|
||||
--
|
||||
-- Adoption (§9): every existing app must have a parent from day one so
|
||||
-- resolution always terminates. This migration seeds a single instance
|
||||
-- root group and reparents every app under it, then promotes
|
||||
-- apps.group_id to NOT NULL.
|
||||
|
||||
CREATE TABLE groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- Single parent keeps inheritance acyclic and resolution
|
||||
-- deterministic. NULL parent = a root node. RESTRICT (never CASCADE):
|
||||
-- deleting a non-empty group is refused so descendant apps and their
|
||||
-- isolated data can't be destroyed implicitly (§5.6). The ancestor-walk
|
||||
-- cycle guard that keeps this acyclic lives in manager-core (a SQL
|
||||
-- CHECK can't express it).
|
||||
parent_id UUID REFERENCES groups(id) ON DELETE RESTRICT,
|
||||
-- Instance-global identifier, frozen at creation. A rename/reparent
|
||||
-- updates the display name/path but NEVER rewrites the slug, so the
|
||||
-- deployment key stays stable and external references don't break.
|
||||
-- Format validation lives in Rust handlers (same rule as app slugs).
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- Per-subtree structure version (§6): bumped on every structural
|
||||
-- mutation of this node (reparent/rename/delete) so a future CLI/
|
||||
-- orchestrator can detect structural drift. NOT an authz input —
|
||||
-- authorization is resolved live every request.
|
||||
structure_version BIGINT NOT NULL DEFAULT 1,
|
||||
-- §7 ownership seam — the project-root that manages this node. Inert
|
||||
-- in Phase 2 (no projects table yet); nullable, no FK.
|
||||
owner_project UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX groups_parent_id_idx ON groups (parent_id);
|
||||
|
||||
-- Per-(user, group) explicit grant, mirroring app_members. Inherited
|
||||
-- membership (GitLab-style) is resolved in code by walking ancestors: a
|
||||
-- group_admin on any ancestor is implicitly app_admin on every app and
|
||||
-- subgroup beneath it. Roles reuse the SAME three literals as app_members
|
||||
-- so AppRole round-trips with zero mapping and the authz rank table
|
||||
-- covers both tables.
|
||||
CREATE TABLE group_members (
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('app_admin', 'editor', 'viewer')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (group_id, user_id)
|
||||
);
|
||||
|
||||
-- Hot path is the authz ancestor walk "what groups does this user have a
|
||||
-- role on?" plus the per-group member list.
|
||||
CREATE INDEX group_members_user_id_idx ON group_members (user_id);
|
||||
|
||||
-- Add the parent pointer to apps (nullable for the backfill window).
|
||||
ALTER TABLE apps ADD COLUMN group_id UUID;
|
||||
|
||||
-- Seed a single instance root group and reparent every existing app under
|
||||
-- it. App slugs are already instance-global, so no slug rewrite is needed
|
||||
-- — the parent pointer is new metadata layered on top.
|
||||
WITH root_group AS (
|
||||
INSERT INTO groups (slug, name, description)
|
||||
VALUES ('root', 'Root', 'The instance root group — parent of all apps created before groups landed.')
|
||||
RETURNING id
|
||||
)
|
||||
UPDATE apps SET group_id = (SELECT id FROM root_group);
|
||||
|
||||
-- Every app now has a parent; promote to NOT NULL + FK. RESTRICT so a
|
||||
-- group with apps can't be deleted out from under them (§5.6).
|
||||
ALTER TABLE apps ALTER COLUMN group_id SET NOT NULL;
|
||||
ALTER TABLE apps
|
||||
ADD CONSTRAINT apps_group_id_fk FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT;
|
||||
|
||||
CREATE INDEX apps_group_id_idx ON apps (group_id);
|
||||
@@ -246,6 +246,8 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
} else {
|
||||
Some(input.sandbox)
|
||||
},
|
||||
// Scripts are created active; toggling is a dedicated path.
|
||||
enabled: true,
|
||||
imports: validated.imports,
|
||||
})
|
||||
.await?;
|
||||
@@ -258,7 +260,7 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
/// real KV bridge — defense against author confusion, not a security
|
||||
/// boundary (stdlib namespaces and module imports already live in
|
||||
/// disjoint Rhai scopes).
|
||||
const RESERVED_MODULE_NAMES: &[&str] = &[
|
||||
pub(crate) const RESERVED_MODULE_NAMES: &[&str] = &[
|
||||
"log",
|
||||
"regex",
|
||||
"random",
|
||||
@@ -345,6 +347,7 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
memory_limit_mb: input.memory_limit_mb,
|
||||
sandbox: input.sandbox,
|
||||
kind: input.kind,
|
||||
enabled: None,
|
||||
imports: imports_for_patch,
|
||||
},
|
||||
)
|
||||
@@ -476,10 +479,9 @@ impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
Self::AppNotFound(_)
|
||||
| Self::BadRequest(_)
|
||||
| Self::Invalid(_)
|
||||
| Self::Ceiling(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
|
||||
Self::AppNotFound(_) | Self::BadRequest(_) | Self::Invalid(_) | Self::Ceiling(_) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||
}
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||
Self::AuthzRepo(e) => {
|
||||
|
||||
@@ -68,6 +68,7 @@ async fn seed_into(
|
||||
timeout_seconds: Some(5),
|
||||
memory_limit_mb: None,
|
||||
sandbox: None,
|
||||
enabled: true,
|
||||
imports: Vec::new(),
|
||||
})
|
||||
.await?;
|
||||
@@ -85,6 +86,7 @@ async fn seed_into(
|
||||
// `curl -d '{"name":"X"}' /hello` work out of the box.
|
||||
method: None,
|
||||
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
||||
enabled: true,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -8,11 +8,17 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
|
||||
use picloud_shared::{AdminUserId, AppId, AppRole, GroupId, InstanceRole, UserId};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
|
||||
/// SQL fragment ranking the three role literals by authority so a CTE can
|
||||
/// `MAX` over a mixed set of app_members + group_members rows. Shared by
|
||||
/// both effective-role queries.
|
||||
const ROLE_RANK_CTE: &str =
|
||||
"role_rank(role, rank) AS (VALUES ('viewer', 1), ('editor', 2), ('app_admin', 3))";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppMembersRepositoryError {
|
||||
#[error("database error: {0}")]
|
||||
@@ -281,13 +287,95 @@ impl AppMembersRepository for PostgresAppMembersRepository {
|
||||
impl AuthzRepo for PostgresAppMembersRepository {
|
||||
async fn membership(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
self.find(user_id, app_id)
|
||||
.await
|
||||
.map_err(|e| AuthzError::Repo(e.to_string()))
|
||||
}
|
||||
|
||||
/// Highest effective role on `app_id`: one recursive CTE walks the
|
||||
/// app's group chain (app.group_id → groups.parent_id → … → root,
|
||||
/// depth-bounded), then `MAX`es the app's own `app_members` row with
|
||||
/// every ancestor `group_members` row by authority rank. A single
|
||||
/// round-trip regardless of tree depth. `None` = no grant anywhere.
|
||||
async fn effective_app_role(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
let row: Option<(String,)> = sqlx::query_as(&format!(
|
||||
"WITH RECURSIVE ancestors AS (
|
||||
SELECT a.group_id AS gid, 0 AS depth FROM apps a WHERE a.id = $2
|
||||
UNION ALL
|
||||
SELECT g.parent_id, anc.depth + 1
|
||||
FROM groups g JOIN ancestors anc ON g.id = anc.gid
|
||||
WHERE g.parent_id IS NOT NULL AND anc.depth < 64
|
||||
),
|
||||
{ROLE_RANK_CTE}
|
||||
SELECT eff.role
|
||||
FROM (
|
||||
SELECT am.role FROM app_members am
|
||||
WHERE am.user_id = $1 AND am.app_id = $2
|
||||
UNION ALL
|
||||
SELECT gm.role FROM group_members gm
|
||||
JOIN ancestors anc ON anc.gid = gm.group_id
|
||||
WHERE gm.user_id = $1
|
||||
) eff
|
||||
JOIN role_rank rr ON rr.role = eff.role
|
||||
ORDER BY rr.rank DESC
|
||||
LIMIT 1"
|
||||
))
|
||||
.bind(user_id.into_inner())
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AuthzError::Repo(e.to_string()))?;
|
||||
row.map(|(role,)| {
|
||||
AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
|
||||
"invalid role {role:?} in members table"
|
||||
)))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Highest effective role on a *group* node — walks the group's own
|
||||
/// ancestor chain over `group_members`. Gates group management.
|
||||
async fn effective_group_role(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
group_id: GroupId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
let row: Option<(String,)> = sqlx::query_as(&format!(
|
||||
"WITH RECURSIVE ancestors AS (
|
||||
SELECT id AS gid, parent_id, 0 AS depth FROM groups WHERE id = $2
|
||||
UNION ALL
|
||||
SELECT g.id, g.parent_id, anc.depth + 1
|
||||
FROM groups g JOIN ancestors anc ON g.id = anc.parent_id
|
||||
WHERE anc.depth < 64
|
||||
),
|
||||
{ROLE_RANK_CTE}
|
||||
SELECT gm.role
|
||||
FROM group_members gm
|
||||
JOIN ancestors anc ON anc.gid = gm.group_id
|
||||
JOIN role_rank rr ON rr.role = gm.role
|
||||
WHERE gm.user_id = $1
|
||||
ORDER BY rr.rank DESC
|
||||
LIMIT 1"
|
||||
))
|
||||
.bind(user_id.into_inner())
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AuthzError::Repo(e.to_string()))?;
|
||||
row.map(|(role,)| {
|
||||
AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
|
||||
"invalid role {role:?} in group_members table"
|
||||
)))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! that writes the history row in the same transaction.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{AdminUserId, App, AppId};
|
||||
use picloud_shared::{AdminUserId, App, AppId, GroupId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -55,6 +55,8 @@ pub trait AppRepository: Send + Sync {
|
||||
/// Only apps the user has an `app_members` row for. Drives the
|
||||
/// membership-filtered `GET /admin/apps` for `member` callers.
|
||||
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||
/// Apps whose parent is `group_id`. Drives the group detail view.
|
||||
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||
async fn get_by_slug_or_history(
|
||||
@@ -67,6 +69,7 @@ pub trait AppRepository: Send + Sync {
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
group_id: GroupId,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
/// Create that also consumes a matching `app_slug_history` row, if
|
||||
/// any. Used after the operator has confirmed they want to break old
|
||||
@@ -76,6 +79,7 @@ pub trait AppRepository: Send + Sync {
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
group_id: GroupId,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
async fn update(
|
||||
&self,
|
||||
@@ -116,7 +120,7 @@ impl PostgresAppRepository {
|
||||
impl AppRepository for PostgresAppRepository {
|
||||
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps ORDER BY name",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -126,7 +130,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
|
||||
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.group_id, a.created_at, a.updated_at \
|
||||
FROM apps a \
|
||||
JOIN app_members m ON m.app_id = a.id \
|
||||
WHERE m.user_id = $1 \
|
||||
@@ -138,9 +142,20 @@ impl AppRepository for PostgresAppRepository {
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps 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 get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
@@ -151,7 +166,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps WHERE slug = $1",
|
||||
)
|
||||
.bind(slug)
|
||||
@@ -181,7 +196,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
|
||||
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.group_id, a.created_at, a.updated_at \
|
||||
FROM app_slug_history h \
|
||||
JOIN apps a ON a.id = h.current_app_id \
|
||||
WHERE h.slug = $1",
|
||||
@@ -197,15 +212,17 @@ impl AppRepository for PostgresAppRepository {
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
group_id: GroupId,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, AppRow>(
|
||||
"INSERT INTO apps (slug, name, description) \
|
||||
VALUES ($1, $2, $3) \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
"INSERT INTO apps (slug, name, description, group_id) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
@@ -223,6 +240,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
group_id: GroupId,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||
@@ -230,13 +248,14 @@ impl AppRepository for PostgresAppRepository {
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"INSERT INTO apps (slug, name, description) \
|
||||
VALUES ($1, $2, $3) \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
"INSERT INTO apps (slug, name, description, group_id) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
let row = match row {
|
||||
@@ -264,7 +283,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(name)
|
||||
@@ -298,7 +317,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
if current_slug == new_slug {
|
||||
// No-op rename; just return the row.
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
@@ -357,7 +376,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"UPDATE apps SET slug = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(new_slug)
|
||||
@@ -432,6 +451,7 @@ struct AppRow {
|
||||
slug: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
group_id: uuid::Uuid,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
@@ -443,6 +463,7 @@ impl From<AppRow> for App {
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
group_id: r.group_id.into(),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::apply_service::{ApplyError, ApplyReport, ApplyService, Bundle, BundleTrigger, Plan};
|
||||
use crate::apply_service::{
|
||||
ApplyError, ApplyReport, ApplyService, Bundle, BundleTrigger, PlanResult,
|
||||
};
|
||||
use crate::authz::{require, AuthzDenied, Capability};
|
||||
|
||||
/// Build the apply/plan router. Mounted under `/api/v1/admin`.
|
||||
@@ -32,6 +34,10 @@ pub struct ApplyRequest {
|
||||
pub bundle: Bundle,
|
||||
#[serde(default)]
|
||||
pub prune: bool,
|
||||
/// Optional bound-plan token from a prior `plan`. When present, apply
|
||||
/// refuses (409) if the app's live state has changed since.
|
||||
#[serde(default)]
|
||||
pub expected_token: Option<String>,
|
||||
}
|
||||
|
||||
async fn apply_handler(
|
||||
@@ -89,7 +95,13 @@ async fn apply_handler(
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
let report = svc
|
||||
.apply(app_id, &req.bundle, req.prune, principal.user_id)
|
||||
.apply(
|
||||
app_id,
|
||||
&req.bundle,
|
||||
req.prune,
|
||||
principal.user_id,
|
||||
req.expected_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
@@ -99,7 +111,7 @@ async fn plan_handler(
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(bundle): Json<Bundle>,
|
||||
) -> Result<Json<Plan>, ApplyError> {
|
||||
) -> Result<Json<PlanResult>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
// NOTE: the returned `Plan` discloses live secret NAMES (not values). That
|
||||
// is safe today only because `AppRead` and `AppSecretsRead` are co-granted
|
||||
@@ -139,6 +151,7 @@ impl IntoResponse for ApplyError {
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "apply authz repo error");
|
||||
|
||||
@@ -90,6 +90,10 @@ pub struct BundleScript {
|
||||
pub memory_limit_mb: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
/// Three-state lifecycle (§4.3); omitted ⇒ active. Declarative (not
|
||||
/// leave-as-is): a script absent the key is `enabled = true`.
|
||||
#[serde(default = "picloud_shared::default_true")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -107,6 +111,9 @@ pub struct BundleRoute {
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub dispatch_mode: DispatchMode,
|
||||
/// Three-state lifecycle (§4.3); omitted ⇒ active.
|
||||
#[serde(default = "picloud_shared::default_true")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Desired trigger, tagged by kind on the wire (`{ "kind": "cron", … }`).
|
||||
@@ -312,6 +319,16 @@ impl Plan {
|
||||
}
|
||||
}
|
||||
|
||||
/// What `plan` returns: the diff plus a fingerprint of the live state it was
|
||||
/// computed against. The token is flattened onto the plan JSON, so the wire
|
||||
/// shape stays `{ scripts, routes, triggers, secrets, state_token }`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PlanResult {
|
||||
#[serde(flatten)]
|
||||
pub plan: Plan,
|
||||
pub state_token: String,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Current state snapshot
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -335,6 +352,12 @@ pub enum ApplyError {
|
||||
AppNotFound(String),
|
||||
#[error("invalid manifest: {0}")]
|
||||
Invalid(String),
|
||||
#[error(
|
||||
"live state changed since `pic plan` (someone edited this app's scripts, \
|
||||
routes, triggers, or secrets); re-run `pic plan` to review, then apply — \
|
||||
or `pic apply --force` to skip the check"
|
||||
)]
|
||||
StateMoved,
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
@@ -347,8 +370,8 @@ pub enum ApplyError {
|
||||
// Service
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Reconcile engine. Holds trait-object repos for the read/diff path;
|
||||
/// the transactional write path (next milestone) reuses the same handles.
|
||||
/// Reconcile engine. Holds trait-object repos for the read/diff path; the
|
||||
/// transactional write path (`apply`) reuses the same handles plus `pool`.
|
||||
#[derive(Clone)]
|
||||
pub struct ApplyService {
|
||||
/// Pool for the transactional write path (`apply`).
|
||||
@@ -377,17 +400,22 @@ impl ApplyService {
|
||||
///
|
||||
/// # Errors
|
||||
/// `Invalid` for a malformed bundle; `Backend` for repo failures.
|
||||
pub async fn plan(&self, app_id: AppId, bundle: &Bundle) -> Result<Plan, ApplyError> {
|
||||
pub async fn plan(&self, app_id: AppId, bundle: &Bundle) -> Result<PlanResult, ApplyError> {
|
||||
self.validate_bundle(bundle)?;
|
||||
self.validate_route_hosts(app_id, bundle).await?;
|
||||
let current = self.load_current(app_id).await?;
|
||||
validate_email_secrets_present(bundle, ¤t.secret_names)?;
|
||||
Ok(compute_diff(¤t, bundle))
|
||||
Ok(PlanResult {
|
||||
plan: compute_diff(¤t, bundle),
|
||||
// Fingerprint of the live state this plan was computed against, so
|
||||
// `apply` can refuse if the app changed underneath it (§4.2).
|
||||
state_token: state_token(¤t),
|
||||
})
|
||||
}
|
||||
|
||||
/// Reconcile app `app_id` to `bundle` in a single transaction.
|
||||
/// Additive: creates and updates are applied; deletions are surfaced
|
||||
/// in the diff but only executed when prune is set (next milestone).
|
||||
/// Reconcile app `app_id` to `bundle` in a single transaction. Creates
|
||||
/// and updates are always applied; deletions of resources absent from the
|
||||
/// bundle are executed only when `prune` is set (secrets are never pruned).
|
||||
///
|
||||
/// # Errors
|
||||
/// `Invalid` for a bad bundle; `Backend` for repo/transaction failures.
|
||||
@@ -398,6 +426,7 @@ impl ApplyService {
|
||||
bundle: &Bundle,
|
||||
prune: bool,
|
||||
actor: AdminUserId,
|
||||
expected_token: Option<&str>,
|
||||
) -> Result<ApplyReport, ApplyError> {
|
||||
self.validate_bundle(bundle)?;
|
||||
self.validate_route_hosts(app_id, bundle).await?;
|
||||
@@ -422,11 +451,31 @@ impl ApplyService {
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
|
||||
let current = self.load_current(app_id).await?;
|
||||
// Bound-plan check (§4.2): if the caller passed the token from a prior
|
||||
// `pic plan`, refuse when the live state changed since. Computed from
|
||||
// the same `load_current` snapshot the diff uses, before any mutation
|
||||
// (so a stale apply rolls back nothing). NOTE: like the diff, this read
|
||||
// is on the pool, not `&mut *tx` (see the lock comment above), so it
|
||||
// catches drift between plan and apply but shares that same narrow
|
||||
// apply-vs-interactive-write window — it is not a substitute for the
|
||||
// tx-scoped read the follow-up will add.
|
||||
if let Some(expected) = expected_token {
|
||||
if state_token(¤t) != expected {
|
||||
return Err(ApplyError::StateMoved);
|
||||
}
|
||||
}
|
||||
// Surface a missing email-secret reference here so `plan` and `apply`
|
||||
// agree, rather than only failing deep in `resolve_and_seal` below.
|
||||
validate_email_secrets_present(bundle, ¤t.secret_names)?;
|
||||
let plan = compute_diff(¤t, bundle);
|
||||
let mut report = ApplyReport::default();
|
||||
// §4.7 warning: an enabled binding pointing at a disabled script is
|
||||
// deployed-but-unreachable. Not an error (it's valid desired state),
|
||||
// but surfaced so the operator isn't surprised by a silent 404.
|
||||
report.warnings.extend(disabled_target_warnings(bundle));
|
||||
report
|
||||
.warnings
|
||||
.extend(unreachable_endpoint_warnings(bundle));
|
||||
|
||||
let bundle_scripts: HashMap<String, &BundleScript> = bundle
|
||||
.scripts
|
||||
@@ -460,6 +509,7 @@ impl ApplyService {
|
||||
timeout_seconds: bs.timeout_seconds,
|
||||
memory_limit_mb: bs.memory_limit_mb,
|
||||
sandbox: bs.sandbox,
|
||||
enabled: bs.enabled,
|
||||
imports: self.script_imports(bs)?,
|
||||
};
|
||||
let created = insert_script_tx(&mut tx, &new).await.map_err(map_repo)?;
|
||||
@@ -471,12 +521,17 @@ impl ApplyService {
|
||||
let cur = current_scripts[&key];
|
||||
let patch = ScriptPatch {
|
||||
name: None,
|
||||
description: Some(bs.description.clone()),
|
||||
// Sparse: `None` (omitted in the manifest) leaves the
|
||||
// stored description untouched; `Some(text)` sets it.
|
||||
// Mirrors `script_update_reason`'s leave-as-is rule.
|
||||
description: bs.description.clone().map(Some),
|
||||
source: Some(bs.source.clone()),
|
||||
timeout_seconds: bs.timeout_seconds,
|
||||
memory_limit_mb: bs.memory_limit_mb,
|
||||
sandbox: bs.sandbox,
|
||||
kind: Some(bs.kind),
|
||||
// Declarative: always reconcile to the manifest's value.
|
||||
enabled: Some(bs.enabled),
|
||||
imports: Some(self.script_imports(bs)?),
|
||||
};
|
||||
update_script_tx(&mut tx, cur.id, &patch)
|
||||
@@ -831,6 +886,15 @@ impl ApplyService {
|
||||
)));
|
||||
}
|
||||
let res = if s.kind == ScriptKind::Module {
|
||||
// Parity with the interactive create path (`api.rs`): a module
|
||||
// may not shadow a built-in SDK namespace, or `import "kv"`
|
||||
// could resolve to a user module instead of the real bridge.
|
||||
if crate::api::RESERVED_MODULE_NAMES.contains(&s.name.as_str()) {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"script `{}`: reserved module name (shadows a built-in SDK namespace)",
|
||||
s.name
|
||||
)));
|
||||
}
|
||||
self.validator.validate_module(&s.source)
|
||||
} else {
|
||||
self.validator.validate(&s.source)
|
||||
@@ -909,49 +973,7 @@ impl ApplyService {
|
||||
t.script()
|
||||
)));
|
||||
}
|
||||
match t {
|
||||
BundleTrigger::Email {
|
||||
inbound_secret_ref, ..
|
||||
} if inbound_secret_ref.trim().is_empty() => {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"email trigger on `{}` needs inbound_secret_ref (a secret name)",
|
||||
t.script()
|
||||
)));
|
||||
}
|
||||
BundleTrigger::Queue {
|
||||
queue_name,
|
||||
visibility_timeout_secs,
|
||||
..
|
||||
} => {
|
||||
if queue_name.trim().is_empty() {
|
||||
return Err(ApplyError::Invalid("queue trigger needs queue_name".into()));
|
||||
}
|
||||
if let Some(v) = visibility_timeout_secs {
|
||||
if !(5..=3600).contains(v) {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"queue `{queue_name}`: visibility_timeout_secs must be in [5, 3600]"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
BundleTrigger::Cron {
|
||||
schedule, timezone, ..
|
||||
} => {
|
||||
crate::cron_scheduler::validate_schedule(schedule).map_err(|e| {
|
||||
ApplyError::Invalid(format!(
|
||||
"cron trigger on `{}`: invalid schedule: {e}",
|
||||
t.script()
|
||||
))
|
||||
})?;
|
||||
crate::cron_scheduler::validate_timezone(timezone).map_err(|e| {
|
||||
ApplyError::Invalid(format!(
|
||||
"cron trigger on `{}`: invalid timezone: {e}",
|
||||
t.script()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
validate_trigger_shape(t)?;
|
||||
let id = t.identity();
|
||||
if !trig_keys.insert(id.clone()) {
|
||||
return Err(ApplyError::Invalid(format!("duplicate trigger `{id}`")));
|
||||
@@ -1084,8 +1106,21 @@ fn script_update_reason(cur: &Script, desired: &BundleScript) -> Option<String>
|
||||
if cur.kind != desired.kind {
|
||||
return Some("kind changed".into());
|
||||
}
|
||||
if cur.description != desired.description {
|
||||
return Some("description changed".into());
|
||||
// Declarative (default true): always reconcile to the manifest's value.
|
||||
if cur.enabled != desired.enabled {
|
||||
return Some(if desired.enabled {
|
||||
"enabled".into()
|
||||
} else {
|
||||
"disabled".into()
|
||||
});
|
||||
}
|
||||
// Sparse like the other optional fields: an omitted (`None`) description
|
||||
// means "leave as-is", so only an explicitly-set value that differs
|
||||
// forces an update. (Clearing a description is done in the dashboard.)
|
||||
if let Some(d) = &desired.description {
|
||||
if cur.description.as_ref() != Some(d) {
|
||||
return Some("description changed".into());
|
||||
}
|
||||
}
|
||||
if let Some(t) = desired.timeout_seconds {
|
||||
if i64::from(cur.timeout_seconds) != i64::from(t) {
|
||||
@@ -1160,6 +1195,7 @@ fn diff_routes(
|
||||
if cur_script != Some(r.script.as_str())
|
||||
|| cur.dispatch_mode != r.dispatch_mode
|
||||
|| cur.host_param_name != r.host_param_name
|
||||
|| cur.enabled != r.enabled
|
||||
{
|
||||
out.push(ResourceChange {
|
||||
op: Op::Update,
|
||||
@@ -1286,6 +1322,12 @@ fn diff_secrets(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange>
|
||||
/// Reject any email trigger whose referenced secret isn't set, so `plan` and
|
||||
/// `apply` give the same answer (apply also fails in `resolve_and_seal`, but
|
||||
/// only after taking the lock — surfacing it here keeps plan honest).
|
||||
///
|
||||
/// This checks the secret *reference exists*. `resolve_and_seal` additionally
|
||||
/// requires the resolved value to be a non-empty string; `plan` deliberately
|
||||
/// does not decrypt secrets, so a reference to a set-but-empty secret passes
|
||||
/// `plan` and is caught at `apply` — the one residual plan/apply divergence,
|
||||
/// and a deliberate one (plan stays read-only and crypto-free).
|
||||
fn validate_email_secrets_present(
|
||||
bundle: &Bundle,
|
||||
secret_names: &[String],
|
||||
@@ -1396,6 +1438,156 @@ fn reject_reserved_path(path: &str) -> Result<(), ApplyError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// §4.7 reachability warning: an *enabled* endpoint script with no route and
|
||||
/// no trigger has no event surface — it's only reachable via the
|
||||
/// execute-by-id bypass / `invoke()`. Modules are exempt (never invoked
|
||||
/// directly); disabled endpoints are intentionally inert, so skip them.
|
||||
fn unreachable_endpoint_warnings(bundle: &Bundle) -> Vec<String> {
|
||||
let bound: HashSet<&str> = bundle
|
||||
.routes
|
||||
.iter()
|
||||
.map(|r| r.script.as_str())
|
||||
.chain(bundle.triggers.iter().map(BundleTrigger::script))
|
||||
.collect();
|
||||
bundle
|
||||
.scripts
|
||||
.iter()
|
||||
.filter(|s| s.kind == ScriptKind::Endpoint && s.enabled && !bound.contains(s.name.as_str()))
|
||||
.map(|s| {
|
||||
format!(
|
||||
"endpoint `{}` has no route or trigger — only reachable via the \
|
||||
execute-by-id bypass / invoke()",
|
||||
s.name
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// §4.7 reachability warnings: an *enabled* route or trigger bound to a
|
||||
/// script the manifest marks *disabled* is deployed but unreachable (the
|
||||
/// route 404s, the trigger won't fire). Valid desired state, so a warning —
|
||||
/// not an error — keyed on the bundle alone.
|
||||
fn disabled_target_warnings(bundle: &Bundle) -> Vec<String> {
|
||||
let disabled: HashSet<&str> = bundle
|
||||
.scripts
|
||||
.iter()
|
||||
.filter(|s| !s.enabled)
|
||||
.map(|s| s.name.as_str())
|
||||
.collect();
|
||||
if disabled.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for r in &bundle.routes {
|
||||
if r.enabled && disabled.contains(r.script.as_str()) {
|
||||
out.push(format!(
|
||||
"route `{}` is enabled but its script `{}` is disabled — it will 404",
|
||||
route_key(
|
||||
r.method.as_deref(),
|
||||
r.host_kind,
|
||||
&r.host,
|
||||
r.path_kind,
|
||||
&r.path
|
||||
),
|
||||
r.script
|
||||
));
|
||||
}
|
||||
}
|
||||
for t in &bundle.triggers {
|
||||
if disabled.contains(t.script()) {
|
||||
out.push(format!(
|
||||
"{} trigger targets disabled script `{}` — it will not fire",
|
||||
t.kind_str(),
|
||||
t.script()
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Per-kind structural validation for one desired trigger — kept at parity
|
||||
/// with the interactive trigger API so `apply` can't write a trigger the
|
||||
/// dashboard would have rejected. Pure (no live state), so it's unit-tested
|
||||
/// directly. The script-binding checks live in `validate_bundle`.
|
||||
fn validate_trigger_shape(t: &BundleTrigger) -> Result<(), ApplyError> {
|
||||
match t {
|
||||
BundleTrigger::Email {
|
||||
inbound_secret_ref, ..
|
||||
} if inbound_secret_ref.trim().is_empty() => {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"email trigger on `{}` needs inbound_secret_ref (a secret name)",
|
||||
t.script()
|
||||
)));
|
||||
}
|
||||
BundleTrigger::Queue {
|
||||
queue_name,
|
||||
visibility_timeout_secs,
|
||||
..
|
||||
} => {
|
||||
if queue_name.trim().is_empty() {
|
||||
return Err(ApplyError::Invalid("queue trigger needs queue_name".into()));
|
||||
}
|
||||
if let Some(v) = visibility_timeout_secs {
|
||||
// Parity with the interactive API: the floor is the dispatcher
|
||||
// tick/reclaim-cadence minimum (`triggers_api`), and the ceiling
|
||||
// matches `trigger_repo`'s queue check. Apply previously allowed
|
||||
// a [5, 29] floor the dashboard rejects.
|
||||
let min = crate::triggers_api::MIN_QUEUE_VISIBILITY_TIMEOUT_SECS;
|
||||
if !(min..=3600).contains(v) {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"queue `{queue_name}`: visibility_timeout_secs must be in [{min}, 3600]"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
BundleTrigger::Cron {
|
||||
schedule, timezone, ..
|
||||
} => {
|
||||
crate::cron_scheduler::validate_schedule(schedule).map_err(|e| {
|
||||
ApplyError::Invalid(format!(
|
||||
"cron trigger on `{}`: invalid schedule: {e}",
|
||||
t.script()
|
||||
))
|
||||
})?;
|
||||
crate::cron_scheduler::validate_timezone(timezone).map_err(|e| {
|
||||
ApplyError::Invalid(format!(
|
||||
"cron trigger on `{}`: invalid timezone: {e}",
|
||||
t.script()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
// Parity with the interactive trigger API, which rejects an empty
|
||||
// collection_glob (`create_{kv,docs,files}_trigger`).
|
||||
BundleTrigger::Kv {
|
||||
collection_glob, ..
|
||||
}
|
||||
| BundleTrigger::Docs {
|
||||
collection_glob, ..
|
||||
}
|
||||
| BundleTrigger::Files {
|
||||
collection_glob, ..
|
||||
} if collection_glob.trim().is_empty() => {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"{} trigger on `{}` needs a non-empty collection_glob",
|
||||
t.kind_str(),
|
||||
t.script()
|
||||
)));
|
||||
}
|
||||
// Parity with `create_pubsub_trigger`'s topic-pattern check — otherwise
|
||||
// a malformed pattern is written and silently never matches at dispatch.
|
||||
BundleTrigger::Pubsub { topic_pattern, .. } => {
|
||||
picloud_shared::validate_topic_pattern(topic_pattern).map_err(|e| {
|
||||
ApplyError::Invalid(format!(
|
||||
"pubsub trigger on `{}`: invalid topic_pattern: {e}",
|
||||
t.script()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Summary of what an `apply` changed.
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub struct ApplyReport {
|
||||
@@ -1437,6 +1629,7 @@ async fn insert_bundle_route(
|
||||
path: br.path.clone(),
|
||||
method: br.method.clone(),
|
||||
dispatch_mode: br.dispatch_mode,
|
||||
enabled: br.enabled,
|
||||
};
|
||||
insert_route_tx(tx, &new).await.map_err(map_repo)?;
|
||||
Ok(())
|
||||
@@ -1509,6 +1702,84 @@ fn map_trig(e: TriggerRepoError) -> ApplyError {
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable fingerprint of an app's live state, covering exactly what the
|
||||
/// reconcile diff keys on: script identity + write-counter (`version` bumps on
|
||||
/// any script edit), route identity + binding/attrs, trigger semantic identity
|
||||
/// (via `current_trigger_identity`, so dead-letter triggers — which the diff
|
||||
/// ignores — are excluded), and secret names. Any out-of-band change to those
|
||||
/// flips the token, so `plan`-then-`apply` can detect the app moving underneath.
|
||||
///
|
||||
/// Deliberately mirrors the diff's inputs so a mismatch means the *reviewed
|
||||
/// plan* would now differ — not merely that some unrelated row changed.
|
||||
///
|
||||
/// Uses FNV-1a (deterministic across process restarts, unlike `DefaultHasher`'s
|
||||
/// per-process seed) so a token stored by `pic plan` still matches on a later
|
||||
/// `pic apply`. A hash collision can only ever yield a false "unchanged", which
|
||||
/// is the same risk class as not checking at all — never a false refusal.
|
||||
#[must_use]
|
||||
pub fn state_token(current: &CurrentState) -> String {
|
||||
let mut parts: Vec<String> = Vec::with_capacity(
|
||||
current.scripts.len()
|
||||
+ current.routes.len()
|
||||
+ current.triggers.len()
|
||||
+ current.secret_names.len(),
|
||||
);
|
||||
for s in ¤t.scripts {
|
||||
parts.push(format!(
|
||||
"s|{}|{}|{}",
|
||||
s.name.to_lowercase(),
|
||||
s.version,
|
||||
s.enabled
|
||||
));
|
||||
}
|
||||
for r in ¤t.routes {
|
||||
parts.push(format!(
|
||||
"r|{}|{:?}|{:?}|{}|{}",
|
||||
route_key(
|
||||
r.method.as_deref(),
|
||||
r.host_kind,
|
||||
&r.host,
|
||||
r.path_kind,
|
||||
&r.path
|
||||
),
|
||||
r.script_id,
|
||||
r.dispatch_mode,
|
||||
r.host_param_name.as_deref().unwrap_or(""),
|
||||
r.enabled,
|
||||
));
|
||||
}
|
||||
// Mirror exactly what the trigger diff keys on: `current_trigger_identity`
|
||||
// excludes dead-letter triggers (the diff ignores them) and keys on the
|
||||
// semantic identity — NOT raw id/enabled. Hashing dead-letter rows or the
|
||||
// (currently inert) `enabled` flag would force a false refusal over a
|
||||
// change the manifest can't represent.
|
||||
let script_name_by_id: HashMap<ScriptId, String> = current
|
||||
.scripts
|
||||
.iter()
|
||||
.map(|s| (s.id, s.name.clone()))
|
||||
.collect();
|
||||
for t in ¤t.triggers {
|
||||
if let Some(id) = current_trigger_identity(t, &script_name_by_id) {
|
||||
parts.push(format!("t|{id}"));
|
||||
}
|
||||
}
|
||||
for n in ¤t.secret_names {
|
||||
parts.push(format!("k|{n}"));
|
||||
}
|
||||
// Order-independent: sort the per-resource tokens before hashing.
|
||||
parts.sort_unstable();
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for p in &parts {
|
||||
for b in p.as_bytes() {
|
||||
h ^= u64::from(*b);
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
h ^= u64::from(b'\n');
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
format!("{h:016x}")
|
||||
}
|
||||
|
||||
/// Per-app advisory lock key, namespaced so it can't collide with the
|
||||
/// queue-trigger lock space.
|
||||
fn apply_lock_key(app_id: AppId) -> i64 {
|
||||
@@ -1536,6 +1807,7 @@ mod tests {
|
||||
timeout_seconds: 30,
|
||||
sandbox: ScriptSandbox::empty(),
|
||||
memory_limit_mb: 256,
|
||||
enabled: true,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
@@ -1550,6 +1822,7 @@ mod tests {
|
||||
timeout_seconds: None,
|
||||
memory_limit_mb: None,
|
||||
sandbox: None,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1636,6 +1909,7 @@ mod tests {
|
||||
path: "/p".into(),
|
||||
method: Some("POST".into()),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
let current = CurrentState {
|
||||
@@ -1655,6 +1929,7 @@ mod tests {
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/p".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
}],
|
||||
triggers: vec![],
|
||||
secrets: vec![],
|
||||
@@ -1687,6 +1962,7 @@ mod tests {
|
||||
id: TriggerId::from(uuid::Uuid::new_v4()),
|
||||
app_id: AppId::from(uuid::Uuid::nil()),
|
||||
script_id,
|
||||
name: "t".into(),
|
||||
kind,
|
||||
enabled: true,
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
@@ -1731,6 +2007,246 @@ mod tests {
|
||||
assert_eq!(compute_diff(¤t, &chg2).scripts[0].op, Op::Update);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_is_sparse() {
|
||||
// An omitted (`None`) description must mean "leave as-is", matching
|
||||
// the other optional fields — not "clear it".
|
||||
let mut cur = script("a", "let x = 1;");
|
||||
cur.description = Some("kept".into());
|
||||
let current = CurrentState {
|
||||
scripts: vec![cur],
|
||||
..CurrentState::default()
|
||||
};
|
||||
// Omitted → NoOp (leave the stored description alone).
|
||||
let mut omit = empty_bundle();
|
||||
omit.scripts = vec![bundle_script("a", "let x = 1;")];
|
||||
assert_eq!(compute_diff(¤t, &omit).scripts[0].op, Op::NoOp);
|
||||
// Same explicit value → NoOp.
|
||||
let mut same = empty_bundle();
|
||||
let mut s = bundle_script("a", "let x = 1;");
|
||||
s.description = Some("kept".into());
|
||||
same.scripts = vec![s];
|
||||
assert_eq!(compute_diff(¤t, &same).scripts[0].op, Op::NoOp);
|
||||
// Different explicit value → Update.
|
||||
let mut chg = empty_bundle();
|
||||
let mut s2 = bundle_script("a", "let x = 1;");
|
||||
s2.description = Some("changed".into());
|
||||
chg.scripts = vec![s2];
|
||||
assert_eq!(compute_diff(¤t, &chg).scripts[0].op, Op::Update);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_shape_validation_matches_interactive_api() {
|
||||
// Empty collection_glob is rejected (kv/docs/files).
|
||||
assert!(validate_trigger_shape(&BundleTrigger::Kv {
|
||||
script: "h".into(),
|
||||
collection_glob: " ".into(),
|
||||
ops: vec![],
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
})
|
||||
.is_err());
|
||||
// A non-empty glob passes.
|
||||
assert!(validate_trigger_shape(&BundleTrigger::Docs {
|
||||
script: "h".into(),
|
||||
collection_glob: "users".into(),
|
||||
ops: vec![],
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
})
|
||||
.is_ok());
|
||||
// A malformed pubsub topic_pattern (mid-pattern wildcard) is rejected;
|
||||
// a valid one passes.
|
||||
assert!(validate_trigger_shape(&BundleTrigger::Pubsub {
|
||||
script: "h".into(),
|
||||
topic_pattern: "user.*.created".into(),
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
})
|
||||
.is_err());
|
||||
assert!(validate_trigger_shape(&BundleTrigger::Pubsub {
|
||||
script: "h".into(),
|
||||
topic_pattern: "orders.created".into(),
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
})
|
||||
.is_ok());
|
||||
// Queue visibility floor matches the interactive API (>= 30): a value
|
||||
// below the floor is rejected; the floor itself and `None` are ok.
|
||||
let queue = |vis| BundleTrigger::Queue {
|
||||
script: "h".into(),
|
||||
queue_name: "jobs".into(),
|
||||
visibility_timeout_secs: vis,
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
};
|
||||
assert!(validate_trigger_shape(&queue(Some(10))).is_err());
|
||||
assert!(validate_trigger_shape(&queue(Some(
|
||||
crate::triggers_api::MIN_QUEUE_VISIBILITY_TIMEOUT_SECS
|
||||
)))
|
||||
.is_ok());
|
||||
assert!(validate_trigger_shape(&queue(None)).is_ok());
|
||||
// Empty email inbound_secret_ref is rejected.
|
||||
assert!(validate_trigger_shape(&BundleTrigger::Email {
|
||||
script: "h".into(),
|
||||
inbound_secret_ref: " ".into(),
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
})
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_token_is_stable_order_independent_and_sensitive() {
|
||||
let a = script("a", "x"); // version 1
|
||||
let b = script("b", "y");
|
||||
let base = CurrentState {
|
||||
scripts: vec![a.clone(), b.clone()],
|
||||
secret_names: vec!["S".into()],
|
||||
..CurrentState::default()
|
||||
};
|
||||
// Deterministic + order-independent.
|
||||
let reordered = CurrentState {
|
||||
scripts: vec![b.clone(), a.clone()],
|
||||
secret_names: vec!["S".into()],
|
||||
..CurrentState::default()
|
||||
};
|
||||
assert_eq!(state_token(&base), state_token(&reordered));
|
||||
// A script edit bumps `version`, which must flip the token even if the
|
||||
// source-by-name set is otherwise unchanged (out-of-band redeploy).
|
||||
let mut a2 = a.clone();
|
||||
a2.version += 1;
|
||||
let bumped = CurrentState {
|
||||
scripts: vec![a2, b.clone()],
|
||||
secret_names: vec!["S".into()],
|
||||
..CurrentState::default()
|
||||
};
|
||||
assert_ne!(state_token(&base), state_token(&bumped));
|
||||
// Adding a secret name flips it too.
|
||||
let with_secret = CurrentState {
|
||||
scripts: vec![a, b],
|
||||
secret_names: vec!["S".into(), "T".into()],
|
||||
..CurrentState::default()
|
||||
};
|
||||
assert_ne!(state_token(&base), state_token(&with_secret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_token_ignores_dead_letter_triggers() {
|
||||
// The diff ignores dead-letter triggers (not manifest-representable),
|
||||
// so the token must too — otherwise an out-of-band dead-letter change
|
||||
// would force a spurious `--force`.
|
||||
let s = script("h", "x");
|
||||
let sid = s.id;
|
||||
let base = CurrentState {
|
||||
scripts: vec![s.clone()],
|
||||
..CurrentState::default()
|
||||
};
|
||||
let with_dl = CurrentState {
|
||||
scripts: vec![s],
|
||||
triggers: vec![trig(
|
||||
sid,
|
||||
TriggerDetails::DeadLetter {
|
||||
source_filter: None,
|
||||
trigger_id_filter: None,
|
||||
script_id_filter: None,
|
||||
},
|
||||
)],
|
||||
..CurrentState::default()
|
||||
};
|
||||
assert_eq!(
|
||||
state_token(&base),
|
||||
state_token(&with_dl),
|
||||
"dead-letter triggers must not affect the token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_is_declarative_and_diffed() {
|
||||
// A live-active script the manifest marks disabled → Update; the token
|
||||
// is sensitive to the flag so an out-of-band toggle is detectable.
|
||||
let cur = script("a", "let x = 1;"); // enabled: true
|
||||
let base = CurrentState {
|
||||
scripts: vec![cur.clone()],
|
||||
..CurrentState::default()
|
||||
};
|
||||
let mut disable = empty_bundle();
|
||||
let mut bs = bundle_script("a", "let x = 1;");
|
||||
bs.enabled = false;
|
||||
disable.scripts = vec![bs];
|
||||
assert_eq!(compute_diff(&base, &disable).scripts[0].op, Op::Update);
|
||||
|
||||
let mut flipped = cur;
|
||||
flipped.enabled = false;
|
||||
let toggled = CurrentState {
|
||||
scripts: vec![flipped],
|
||||
..CurrentState::default()
|
||||
};
|
||||
assert_ne!(
|
||||
state_token(&base),
|
||||
state_token(&toggled),
|
||||
"token must flip on an enabled change"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warns_on_enabled_binding_to_disabled_script() {
|
||||
let mut b = empty_bundle();
|
||||
let mut s = bundle_script("h", "x");
|
||||
s.enabled = false;
|
||||
b.scripts = vec![s];
|
||||
b.routes = vec![BundleRoute {
|
||||
script: "h".into(),
|
||||
method: None,
|
||||
host_kind: HostKind::Any,
|
||||
host: String::new(),
|
||||
host_param_name: None,
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/h".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
}];
|
||||
let w = disabled_target_warnings(&b);
|
||||
assert!(
|
||||
w.iter().any(|m| m.contains("will 404")),
|
||||
"expected a 404 reachability warning: {w:?}"
|
||||
);
|
||||
// No warning when the script is active.
|
||||
b.scripts[0].enabled = true;
|
||||
assert!(disabled_target_warnings(&b).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warns_on_unreachable_endpoint() {
|
||||
// §4.7: an enabled endpoint with no route and no trigger is flagged.
|
||||
let mut b = empty_bundle();
|
||||
b.scripts = vec![bundle_script("orphan", "x")];
|
||||
let w = unreachable_endpoint_warnings(&b);
|
||||
assert!(
|
||||
w.iter().any(|m| m.contains("no route or trigger")),
|
||||
"expected an unreachable-endpoint warning: {w:?}"
|
||||
);
|
||||
// Bound by a route → no warning.
|
||||
b.routes = vec![BundleRoute {
|
||||
script: "orphan".into(),
|
||||
method: None,
|
||||
host_kind: HostKind::Any,
|
||||
host: String::new(),
|
||||
host_param_name: None,
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/o".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
}];
|
||||
assert!(unreachable_endpoint_warnings(&b).is_empty());
|
||||
// A module is exempt even with no binding.
|
||||
let mut m = empty_bundle();
|
||||
let mut lib = bundle_script("lib", "x");
|
||||
lib.kind = ScriptKind::Module;
|
||||
m.scripts = vec![lib];
|
||||
assert!(unreachable_endpoint_warnings(&m).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_diff_create_noop_delete() {
|
||||
let s = script("h", "x");
|
||||
|
||||
@@ -25,6 +25,7 @@ use uuid::Uuid;
|
||||
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::group_repo::{GroupRepository, ROOT_GROUP_SLUG};
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
use crate::route_repo::RouteRepository;
|
||||
|
||||
@@ -44,6 +45,9 @@ pub struct AppsState {
|
||||
pub domain_table: Arc<AppDomainTable>,
|
||||
/// Capability gate — Phase 3.5.
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
/// Group tree — resolves an app's parent group at create time
|
||||
/// (defaults to the instance root).
|
||||
pub groups: Arc<dyn GroupRepository>,
|
||||
}
|
||||
|
||||
pub fn apps_router(state: AppsState) -> Router {
|
||||
@@ -80,6 +84,10 @@ pub struct CreateAppRequest {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Parent group (slug or id). Defaults to the instance root group when
|
||||
/// omitted — every app has a parent from day one (§9).
|
||||
#[serde(default)]
|
||||
pub group: Option<String>,
|
||||
/// Set to `true` to consume an existing `app_slug_history` row for
|
||||
/// the requested slug (breaking old redirects).
|
||||
#[serde(default)]
|
||||
@@ -176,23 +184,46 @@ async fn create_app(
|
||||
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
|
||||
validate_slug(&input.slug)?;
|
||||
|
||||
// Resolve the parent group: an explicit `group` (slug or id) or the
|
||||
// instance root by default. Placing an app under a specific group
|
||||
// additionally requires group-write there.
|
||||
let parent = resolve_group(&*s.groups, input.group.as_deref()).await?;
|
||||
if input.group.is_some() {
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupWrite(parent.id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Historical-slug check before insert: if the slug is in history
|
||||
// and the caller hasn't asked to force takeover, surface a clean
|
||||
// 409 so the dashboard can present a "this will break old links"
|
||||
// confirmation.
|
||||
if !input.force_takeover {
|
||||
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
|
||||
return Err(AppsApiError::SlugInHistory(current));
|
||||
return Err(AppsApiError::SlugInHistory(Box::new(current)));
|
||||
}
|
||||
}
|
||||
|
||||
let created = if input.force_takeover {
|
||||
s.apps
|
||||
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
|
||||
.create_with_takeover(
|
||||
&input.slug,
|
||||
&input.name,
|
||||
input.description.as_deref(),
|
||||
parent.id,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
s.apps
|
||||
.create(&input.slug, &input.name, input.description.as_deref())
|
||||
.create(
|
||||
&input.slug,
|
||||
&input.name,
|
||||
input.description.as_deref(),
|
||||
parent.id,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
@@ -235,7 +266,31 @@ async fn compute_my_role(
|
||||
) -> Result<Option<AppRole>, AppsApiError> {
|
||||
match principal.instance_role {
|
||||
InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
|
||||
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
|
||||
// Effective role: folds in inherited group memberships so the
|
||||
// dashboard badge reflects what the caller can actually do.
|
||||
InstanceRole::Member => Ok(authz.effective_app_role(principal.user_id, app_id).await?),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an optional group identifier (slug or UUID) to a group,
|
||||
/// defaulting to the instance root group when `None`.
|
||||
async fn resolve_group(
|
||||
groups: &dyn GroupRepository,
|
||||
ident: Option<&str>,
|
||||
) -> Result<picloud_shared::Group, AppsApiError> {
|
||||
match ident {
|
||||
None => groups
|
||||
.get_by_slug(ROOT_GROUP_SLUG)
|
||||
.await?
|
||||
.ok_or_else(|| AppsApiError::GroupNotFound(ROOT_GROUP_SLUG.to_string())),
|
||||
Some(ident) => {
|
||||
let found = if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||
groups.get_by_id(uuid.into()).await?
|
||||
} else {
|
||||
groups.get_by_slug(ident).await?
|
||||
};
|
||||
found.ok_or_else(|| AppsApiError::GroupNotFound(ident.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +334,7 @@ async fn patch_app(
|
||||
Ok(app) => app,
|
||||
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
|
||||
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
|
||||
return Err(AppsApiError::SlugInHistory(current));
|
||||
return Err(AppsApiError::SlugInHistory(Box::new(current)));
|
||||
}
|
||||
return Err(AppsApiError::Conflict(msg));
|
||||
}
|
||||
@@ -521,14 +576,19 @@ pub enum AppsApiError {
|
||||
#[error("app not found: {0}")]
|
||||
AppNotFound(String),
|
||||
|
||||
#[error("group not found: {0}")]
|
||||
GroupNotFound(String),
|
||||
|
||||
#[error("domain not found: {0}")]
|
||||
DomainNotFound(Uuid),
|
||||
|
||||
#[error("invalid slug: {0}")]
|
||||
InvalidSlug(String),
|
||||
|
||||
// Boxed: `App` is large enough to trip clippy::result_large_err on
|
||||
// every handler returning `Result<_, AppsApiError>`.
|
||||
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
|
||||
SlugInHistory(App),
|
||||
SlugInHistory(Box<App>),
|
||||
|
||||
#[error("app still contains {0} script(s); delete or move them first")]
|
||||
HasScripts(i64),
|
||||
@@ -567,10 +627,22 @@ impl From<AuthzError> for AppsApiError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::group_repo::GroupRepositoryError> for AppsApiError {
|
||||
fn from(e: crate::group_repo::GroupRepositoryError) -> Self {
|
||||
use crate::group_repo::GroupRepositoryError as G;
|
||||
match e {
|
||||
G::NotFound(id) => Self::GroupNotFound(id.to_string()),
|
||||
G::Conflict(msg) => Self::Conflict(msg),
|
||||
G::Db(e) => Self::Repo(ScriptRepositoryError::Db(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound(_)
|
||||
| Self::GroupNotFound(_)
|
||||
| Self::DomainNotFound(_)
|
||||
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
//! external user-facing label.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
|
||||
use picloud_shared::{AppId, AppRole, GroupId, InstanceRole, Principal, Scope, UserId};
|
||||
|
||||
/// Things a caller can attempt to do. Each app-scoped variant carries
|
||||
/// the `AppId` of the resource the action targets — handlers compute
|
||||
@@ -37,6 +37,19 @@ use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
|
||||
pub enum Capability {
|
||||
/// Create a new app. Owner / admin only.
|
||||
InstanceCreateApp,
|
||||
/// Create a new group (root-level). Owner / admin only — a Member
|
||||
/// creates subgroups under a group they group-admin (gated by
|
||||
/// `GroupAdmin(parent)` at the handler), not via this instance cap.
|
||||
InstanceCreateGroup,
|
||||
/// Read group metadata + list its subgroups/apps. Viewer+ on the
|
||||
/// group (inherited from any ancestor); implicit for admin / owner.
|
||||
GroupRead(GroupId),
|
||||
/// Rename / edit group metadata, move apps into it. Editor+ on the
|
||||
/// group.
|
||||
GroupWrite(GroupId),
|
||||
/// Group settings: delete, reparent, manage group members. group_admin
|
||||
/// on the group (inherited from any ancestor).
|
||||
GroupAdmin(GroupId),
|
||||
/// Create / update / delete admin_users rows (other than self
|
||||
/// password change, which is a separate flow). Owner / admin.
|
||||
InstanceManageUsers,
|
||||
@@ -154,9 +167,16 @@ impl Capability {
|
||||
#[must_use]
|
||||
pub const fn app_id(self) -> Option<AppId> {
|
||||
match self {
|
||||
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||
None
|
||||
}
|
||||
Self::InstanceCreateApp
|
||||
| Self::InstanceManageUsers
|
||||
| Self::InstanceManageSettings
|
||||
| Self::InstanceCreateGroup
|
||||
// Group-scoped caps carry a GroupId, not an AppId. They return
|
||||
// None here so a bound API key (which can only target its one
|
||||
// app) is denied group management at the binding layer.
|
||||
| Self::GroupRead(_)
|
||||
| Self::GroupWrite(_)
|
||||
| Self::GroupAdmin(_) => None,
|
||||
Self::AppRead(id)
|
||||
| Self::AppWriteScript(id)
|
||||
| Self::AppWriteRoute(id)
|
||||
@@ -193,15 +213,17 @@ impl Capability {
|
||||
#[must_use]
|
||||
pub const fn required_scope(self) -> Scope {
|
||||
match self {
|
||||
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||
Scope::InstanceAdmin
|
||||
}
|
||||
Self::InstanceCreateApp
|
||||
| Self::InstanceManageUsers
|
||||
| Self::InstanceManageSettings
|
||||
| Self::InstanceCreateGroup => Scope::InstanceAdmin,
|
||||
Self::AppRead(_)
|
||||
| Self::AppKvRead(_)
|
||||
| Self::AppDocsRead(_)
|
||||
| Self::AppFilesRead(_)
|
||||
| Self::AppSecretsRead(_)
|
||||
| Self::AppUsersRead(_) => Scope::ScriptRead,
|
||||
| Self::AppUsersRead(_)
|
||||
| Self::GroupRead(_) => Scope::ScriptRead,
|
||||
Self::AppWriteScript(_)
|
||||
| Self::AppKvWrite(_)
|
||||
| Self::AppDocsWrite(_)
|
||||
@@ -219,7 +241,9 @@ impl Capability {
|
||||
Self::AppAdmin(_)
|
||||
| Self::AppManageTriggers(_)
|
||||
| Self::AppDeadLetterManage(_)
|
||||
| Self::AppTopicManage(_) => Scope::AppAdmin,
|
||||
| Self::AppTopicManage(_)
|
||||
| Self::GroupWrite(_)
|
||||
| Self::GroupAdmin(_) => Scope::AppAdmin,
|
||||
Self::AppLogRead(_) => Scope::LogRead,
|
||||
}
|
||||
}
|
||||
@@ -230,11 +254,41 @@ impl Capability {
|
||||
/// means unit tests can stub it.
|
||||
#[async_trait]
|
||||
pub trait AuthzRepo: Send + Sync {
|
||||
/// Direct `app_members` row for (user, app). The single-row lookup
|
||||
/// used by member-management surfaces and as the fallback below.
|
||||
async fn membership(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError>;
|
||||
|
||||
/// Highest *effective* role on `app_id` (hierarchy-aware RBAC, §5.3):
|
||||
/// the app's own `app_members` row folded with every `group_members`
|
||||
/// row on any ancestor group, max-by-authority. This is what `can()`
|
||||
/// consults so a `group_admin` on an ancestor is implicitly app_admin
|
||||
/// on the app.
|
||||
///
|
||||
/// Default = direct membership only (no inheritance), so the many test
|
||||
/// stubs that model no group tree keep their existing behavior; the
|
||||
/// Postgres repo overrides this with an ancestor-walking CTE.
|
||||
async fn effective_app_role(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
self.membership(user_id, app_id).await
|
||||
}
|
||||
|
||||
/// Highest effective role on a *group* node — the group's own
|
||||
/// ancestor walk over `group_members`. Gates the group-management
|
||||
/// capabilities. Default = no grant; the Postgres repo overrides it.
|
||||
async fn effective_group_role(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_group_id: GroupId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Repo errors surface here so handlers can map them to 500 without
|
||||
@@ -353,7 +407,20 @@ async fn role_grants(
|
||||
match principal.instance_role {
|
||||
InstanceRole::Owner => Ok(true),
|
||||
InstanceRole::Admin => Ok(admin_grants(cap)),
|
||||
InstanceRole::Member => member_grants(repo, principal.user_id, cap).await,
|
||||
InstanceRole::Member => match cap {
|
||||
// Group-management caps resolve against the group ancestor
|
||||
// walk (a group_admin on an ancestor is implicitly admin of
|
||||
// the descendant group). Routed before member_grants because
|
||||
// group caps carry no app_id.
|
||||
Capability::GroupRead(g) | Capability::GroupWrite(g) | Capability::GroupAdmin(g) => {
|
||||
group_member_grants(repo, principal.user_id, cap, g).await
|
||||
}
|
||||
// Creating a root-level group is an instance act — members
|
||||
// can't. (Subgroup creation is gated on GroupAdmin(parent) at
|
||||
// the handler, which routes through the arm above.)
|
||||
Capability::InstanceCreateGroup => Ok(false),
|
||||
_ => member_grants(repo, principal.user_id, cap).await,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,12 +444,41 @@ async fn member_grants(
|
||||
let Some(app_id) = cap.app_id() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(role) = repo.membership(user_id, app_id).await? else {
|
||||
// Effective (inherited) role: the app's own membership folded with any
|
||||
// ancestor group membership. A group_admin on an ancestor group is
|
||||
// implicitly app_admin here.
|
||||
let Some(role) = repo.effective_app_role(user_id, app_id).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(role_satisfies(role, cap))
|
||||
}
|
||||
|
||||
/// Member-path resolution for the group-management capabilities. Resolves
|
||||
/// the caller's effective role on the group (ancestor walk over
|
||||
/// `group_members`) and checks it covers the requested group action.
|
||||
async fn group_member_grants(
|
||||
repo: &dyn AuthzRepo,
|
||||
user_id: UserId,
|
||||
cap: Capability,
|
||||
group_id: GroupId,
|
||||
) -> Result<bool, AuthzError> {
|
||||
let Some(role) = repo.effective_group_role(user_id, group_id).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(group_role_satisfies(role, cap))
|
||||
}
|
||||
|
||||
/// Does the effective group `AppRole` cover the group capability?
|
||||
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
|
||||
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
match cap {
|
||||
Capability::GroupRead(_) => true, // any role can read
|
||||
Capability::GroupWrite(_) => matches!(role, AppRole::Editor | AppRole::AppAdmin),
|
||||
Capability::GroupAdmin(_) => matches!(role, AppRole::AppAdmin),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the per-app `AppRole` cover the capability? Viewer can read;
|
||||
/// Editor adds script/route/log mutations; AppAdmin adds settings,
|
||||
/// domain claims, and delete. Roles form a strict subset chain, so
|
||||
@@ -471,16 +567,61 @@ mod tests {
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// In-memory `AuthzRepo` so the unit tests don't need a database.
|
||||
/// In-memory `AuthzRepo` so the unit tests don't need a database. Models
|
||||
/// direct app memberships PLUS a group tree (app→group, group→parent)
|
||||
/// and group memberships, so the hierarchy-aware resolution can be
|
||||
/// exercised without Postgres — mirroring the recursive-CTE behavior.
|
||||
#[derive(Default)]
|
||||
struct InMemoryAuthzRepo {
|
||||
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
|
||||
app_group: Mutex<HashMap<AppId, GroupId>>,
|
||||
group_parent: Mutex<HashMap<GroupId, Option<GroupId>>>,
|
||||
group_memberships: Mutex<HashMap<(UserId, GroupId), AppRole>>,
|
||||
}
|
||||
|
||||
impl InMemoryAuthzRepo {
|
||||
async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
|
||||
self.memberships.lock().await.insert((user, app), role);
|
||||
}
|
||||
/// Register a group node and its parent (`None` = root).
|
||||
async fn add_group(&self, group: GroupId, parent: Option<GroupId>) {
|
||||
self.group_parent.lock().await.insert(group, parent);
|
||||
}
|
||||
/// Place an app under a group.
|
||||
async fn put_app(&self, app: AppId, group: GroupId) {
|
||||
self.app_group.lock().await.insert(app, group);
|
||||
}
|
||||
/// Grant a group-level role.
|
||||
async fn grant_group(&self, user: UserId, group: GroupId, role: AppRole) {
|
||||
self.group_memberships
|
||||
.lock()
|
||||
.await
|
||||
.insert((user, group), role);
|
||||
}
|
||||
|
||||
/// Fold every ancestor group membership starting at `group`,
|
||||
/// max-by-authority, into `acc`.
|
||||
async fn fold_group_chain(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
mut group: Option<GroupId>,
|
||||
mut acc: Option<AppRole>,
|
||||
) -> Option<AppRole> {
|
||||
let memberships = self.group_memberships.lock().await;
|
||||
let parents = self.group_parent.lock().await;
|
||||
let mut hops = 0u32;
|
||||
while let Some(g) = group {
|
||||
if let Some(r) = memberships.get(&(user_id, g)).copied() {
|
||||
acc = Some(acc.map_or(r, |a| a.max(r)));
|
||||
}
|
||||
hops += 1;
|
||||
if hops > 64 {
|
||||
break;
|
||||
}
|
||||
group = parents.get(&g).copied().flatten();
|
||||
}
|
||||
acc
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -497,6 +638,29 @@ mod tests {
|
||||
.get(&(user_id, app_id))
|
||||
.copied())
|
||||
}
|
||||
|
||||
async fn effective_app_role(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
let direct = self
|
||||
.memberships
|
||||
.lock()
|
||||
.await
|
||||
.get(&(user_id, app_id))
|
||||
.copied();
|
||||
let start = self.app_group.lock().await.get(&app_id).copied();
|
||||
Ok(self.fold_group_chain(user_id, start, direct).await)
|
||||
}
|
||||
|
||||
async fn effective_group_role(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
group_id: GroupId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(self.fold_group_chain(user_id, Some(group_id), None).await)
|
||||
}
|
||||
}
|
||||
|
||||
fn principal(role: InstanceRole) -> Principal {
|
||||
@@ -857,12 +1021,183 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Hierarchy-aware RBAC (Phase 2 groups)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_admin_on_ancestor_is_implicit_app_admin() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let acme = GroupId::new();
|
||||
let app = AppId::new();
|
||||
repo.add_group(acme, None).await;
|
||||
repo.put_app(app, acme).await;
|
||||
|
||||
let p = principal(InstanceRole::Member);
|
||||
// No app_members row — authority comes purely from the group.
|
||||
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||
|
||||
for cap in [
|
||||
Capability::AppRead(app),
|
||||
Capability::AppWriteScript(app),
|
||||
Capability::AppAdmin(app),
|
||||
] {
|
||||
assert!(
|
||||
can(&repo, &p, cap).await.unwrap().is_allow(),
|
||||
"inherited group_admin denied {cap:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inherited_role_takes_the_max_of_direct_and_ancestor() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let acme = GroupId::new();
|
||||
let app = AppId::new();
|
||||
repo.add_group(acme, None).await;
|
||||
repo.put_app(app, acme).await;
|
||||
|
||||
let p = principal(InstanceRole::Member);
|
||||
// Direct viewer on the app, app_admin via the ancestor group:
|
||||
// the higher (app_admin) wins.
|
||||
repo.grant(p.user_id, app, AppRole::Viewer).await;
|
||||
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||
|
||||
assert!(can(&repo, &p, Capability::AppAdmin(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_role_inherits_down_a_multi_level_tree() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let root = GroupId::new();
|
||||
let team = GroupId::new();
|
||||
let app = AppId::new();
|
||||
repo.add_group(root, None).await;
|
||||
repo.add_group(team, Some(root)).await;
|
||||
repo.put_app(app, team).await;
|
||||
|
||||
// Editor two levels up flows down to the app as editor.
|
||||
let p = principal(InstanceRole::Member);
|
||||
repo.grant_group(p.user_id, root, AppRole::Editor).await;
|
||||
|
||||
assert!(can(&repo, &p, Capability::AppWriteScript(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||
Decision::Deny,
|
||||
"editor must not get app_admin"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_membership_grants_no_instance_capabilities() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let acme = GroupId::new();
|
||||
repo.add_group(acme, None).await;
|
||||
let p = principal(InstanceRole::Member);
|
||||
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||
|
||||
for cap in [
|
||||
Capability::InstanceCreateApp,
|
||||
Capability::InstanceCreateGroup,
|
||||
Capability::InstanceManageUsers,
|
||||
] {
|
||||
assert_eq!(
|
||||
can(&repo, &p, cap).await.unwrap(),
|
||||
Decision::Deny,
|
||||
"group_admin must not grant instance cap {cap:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_admin_walks_ancestors_for_group_caps() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let root = GroupId::new();
|
||||
let team = GroupId::new();
|
||||
repo.add_group(root, None).await;
|
||||
repo.add_group(team, Some(root)).await;
|
||||
|
||||
let p = principal(InstanceRole::Member);
|
||||
repo.grant_group(p.user_id, root, AppRole::AppAdmin).await;
|
||||
|
||||
// group_admin at root ⇒ admin of the descendant group.
|
||||
assert!(can(&repo, &p, Capability::GroupAdmin(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert!(can(&repo, &p, Capability::GroupWrite(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
|
||||
// An unrelated member gets nothing.
|
||||
let outsider = principal(InstanceRole::Member);
|
||||
assert_eq!(
|
||||
can(&repo, &outsider, Capability::GroupRead(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_implicitly_manages_the_whole_group_tree() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let g = GroupId::new();
|
||||
let p = principal(InstanceRole::Admin);
|
||||
for cap in [
|
||||
Capability::InstanceCreateGroup,
|
||||
Capability::GroupRead(g),
|
||||
Capability::GroupWrite(g),
|
||||
Capability::GroupAdmin(g),
|
||||
] {
|
||||
assert!(
|
||||
can(&repo, &p, cap).await.unwrap().is_allow(),
|
||||
"admin denied group cap {cap:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bound_key_cannot_manage_groups() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let g = GroupId::new();
|
||||
let p = Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: Some(vec![Scope::AppAdmin]),
|
||||
app_binding: Some(AppId::new()),
|
||||
};
|
||||
// Group caps carry no app_id, so a bound key is denied at the
|
||||
// binding layer regardless of role/scope.
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::GroupAdmin(g)).await.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_role_max_is_authority_ordered() {
|
||||
assert_eq!(AppRole::Viewer.max(AppRole::AppAdmin), AppRole::AppAdmin);
|
||||
assert_eq!(AppRole::Editor.max(AppRole::Viewer), AppRole::Editor);
|
||||
assert_eq!(AppRole::AppAdmin.max(AppRole::Editor), AppRole::AppAdmin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_app_id_extraction() {
|
||||
let app = AppId::new();
|
||||
assert_eq!(Capability::InstanceCreateApp.app_id(), None);
|
||||
assert_eq!(Capability::AppRead(app).app_id(), Some(app));
|
||||
assert_eq!(Capability::AppAdmin(app).app_id(), Some(app));
|
||||
// Group caps are not app-scoped.
|
||||
assert_eq!(Capability::GroupAdmin(GroupId::new()).app_id(), None);
|
||||
assert_eq!(Capability::InstanceCreateGroup.app_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -384,6 +384,39 @@ impl Dispatcher {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Fire-time `enabled` re-check (§4.3). The queue arm does not flow
|
||||
// through `dispatch_one`'s unified `!active` gate; its only other
|
||||
// `enabled` guard is the per-tick `list_active_queue_consumers`
|
||||
// snapshot, which is stale for messages already claimed in this
|
||||
// tick. A script disabled after the list query but before this
|
||||
// claimed message executes must NOT run (`script` here is a fresh
|
||||
// read from line ~331, so `enabled` is current). Release the claim
|
||||
// (nack) rather than ack/dead-letter: the message stays queued and
|
||||
// is processed when the script is re-enabled, and the next tick
|
||||
// won't re-claim it because the list filters `s.enabled`. Without
|
||||
// this nack the message would sit claimed indefinitely —
|
||||
// `reclaim_visibility_timeouts` only reclaims for enabled triggers.
|
||||
if !script.enabled {
|
||||
tracing::info!(
|
||||
script_id = %consumer.script_id,
|
||||
trigger_id = %consumer.trigger_id,
|
||||
"queue consumer script disabled at fire time; releasing claim"
|
||||
);
|
||||
if let Err(e) = self
|
||||
.queue
|
||||
.nack(
|
||||
claimed.id,
|
||||
claimed.claim_token,
|
||||
chrono::Duration::seconds(1),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(?e, "queue nack on disabled consumer failed");
|
||||
}
|
||||
drop(permit);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let principal = self
|
||||
.principals
|
||||
.resolve(consumer.registered_by_principal)
|
||||
@@ -546,6 +579,7 @@ impl Dispatcher {
|
||||
// TODO(metrics): bump picloud_queue_dead_letters_total{app_id, queue_name}.
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn dispatch_one(&self, row: OutboxRow) -> Result<(), DispatcherError> {
|
||||
// Depth-limit check — design notes §4: loops aren't DL'd.
|
||||
if row.trigger_depth > self.config.max_trigger_depth {
|
||||
@@ -626,6 +660,26 @@ impl Dispatcher {
|
||||
}
|
||||
};
|
||||
|
||||
// §4.3 fire-time re-check, for EVERY outbox source (trigger, async
|
||||
// HTTP/202, and invoke): if the target script (or, for triggers, the
|
||||
// trigger) was disabled after this row was enqueued, drop it rather
|
||||
// than fire a stale event. The match-time `enabled` check can't see a
|
||||
// later toggle, so this is the gate that makes a disabled script
|
||||
// genuinely non-invocable on the async paths too.
|
||||
if !resolved.active {
|
||||
tracing::debug!(
|
||||
outbox_id = %row.id,
|
||||
app_id = %row.app_id,
|
||||
"target script/trigger disabled since enqueue; dropping outbox row"
|
||||
);
|
||||
self.outbox
|
||||
.delete(row.id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
drop(permit);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// The gate permit auto-releases when this scope ends or when
|
||||
// the executor finishes. We hand control to the executor and
|
||||
// wait synchronously here — sync HTTP and dispatcher share the
|
||||
@@ -722,9 +776,26 @@ impl Dispatcher {
|
||||
DispatcherError::ResolveTrigger(format!("script {} not found", trigger.script_id))
|
||||
})?;
|
||||
|
||||
// Audit 2026-06-11 H-F1 sibling — same-app guard mirroring
|
||||
// build_http_request / build_invoke_request / dispatch_one_queue.
|
||||
// `build_exec_request` stamps `ExecRequest.app_id = row.app_id`
|
||||
// while sourcing the body from `trigger.script_id`; without this
|
||||
// check a hand-edited outbox/trigger row (or a partial restore, or
|
||||
// a script re-pointed across apps) could run one app's script under
|
||||
// another app's `SdkCallCx.app_id` — the cross-app isolation
|
||||
// boundary. Not reachable via the trigger-create or `apply` paths
|
||||
// (both resolve the script within the app's own scope), so this is
|
||||
// the runtime backstop the other arms already carry.
|
||||
if script.app_id != row.app_id {
|
||||
return Err(DispatcherError::ResolveTrigger(
|
||||
"trigger outbox target belongs to a different app".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ResolvedTrigger {
|
||||
trigger_kind: trigger.kind,
|
||||
is_dead_letter_handler: matches!(trigger.kind, TriggerKind::DeadLetter),
|
||||
active: trigger.enabled && script.enabled,
|
||||
script_id: script.id,
|
||||
script_source: script.source,
|
||||
script_name: script.name,
|
||||
@@ -844,6 +915,9 @@ impl Dispatcher {
|
||||
let resolved = ResolvedTrigger {
|
||||
trigger_kind: TriggerKind::Kv, // placeholder; HTTP doesn't have a kind
|
||||
is_dead_letter_handler: false,
|
||||
// §4.3: an async-HTTP (202) row whose script was disabled after
|
||||
// enqueue is dropped at fire time by the post-match active check.
|
||||
active: script.enabled,
|
||||
script_id,
|
||||
script_source: script.source,
|
||||
script_name: payload.script_name,
|
||||
@@ -941,6 +1015,9 @@ impl Dispatcher {
|
||||
let resolved = ResolvedTrigger {
|
||||
trigger_kind: TriggerKind::Cron, // placeholder; not used downstream
|
||||
is_dead_letter_handler: false,
|
||||
// §4.3: a queued invoke() whose target script was disabled after
|
||||
// enqueue is dropped at fire time by the post-match active check.
|
||||
active: script.enabled,
|
||||
script_id: script.id,
|
||||
script_source: script.source,
|
||||
script_name: script.name,
|
||||
@@ -1238,6 +1315,9 @@ impl Dispatcher {
|
||||
pub struct ResolvedTrigger {
|
||||
pub trigger_kind: TriggerKind,
|
||||
pub is_dead_letter_handler: bool,
|
||||
/// §4.3 fire-time gate: `trigger.enabled && script.enabled` at resolve
|
||||
/// time. A row enqueued before either was disabled is dropped, not fired.
|
||||
pub active: bool,
|
||||
pub script_id: ScriptId,
|
||||
pub script_source: String,
|
||||
pub script_name: String,
|
||||
@@ -1504,4 +1584,837 @@ mod tests {
|
||||
);
|
||||
assert_eq!(failure_kind_to_status(InboxFailureKind::Platform), 500);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Queue-arm fire-time `enabled` gate (§4.3).
|
||||
//
|
||||
// `dispatch_one_queue` re-reads the script fresh after claiming a
|
||||
// message and, if it has been disabled since the per-tick
|
||||
// `list_active_queue_consumers` snapshot, releases the claim (nack)
|
||||
// without resolving a principal or executing. This test proves that
|
||||
// gate end-to-end with in-memory stubs.
|
||||
//
|
||||
// Regression property: the principal resolver returns a valid
|
||||
// `Principal` and the executor records `executed = true` before
|
||||
// erroring, so DELETING the `if !script.enabled` gate makes the flow
|
||||
// fall through to resolve → execute, flipping `executed` and failing
|
||||
// `assert!(!executed)`. (Verified by temporarily removing the gate.)
|
||||
// ----------------------------------------------------------------
|
||||
mod queue_enabled_gate {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse};
|
||||
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient};
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, InstanceRole, Principal, Script, ScriptId, ScriptSandbox,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::abandoned_repo::{AbandonedRepo, NewAbandonedExecution};
|
||||
use crate::dead_letter_repo::{DeadLetterRepo, NewDeadLetter};
|
||||
use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxRow};
|
||||
use crate::principal_resolver::{PrincipalResolver, PrincipalResolverError};
|
||||
use crate::queue_repo::{ClaimedMessage, NewQueueMessage, QueueRepo, QueueStats};
|
||||
use crate::repo::{NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError};
|
||||
use crate::trigger_config::BackoffShape;
|
||||
use crate::trigger_repo::{ActiveQueueConsumer, TriggerRepo};
|
||||
|
||||
// ---- ScriptRepository: only `get` is exercised. ----
|
||||
pub(super) struct DisabledScriptRepo {
|
||||
pub(super) script: Script,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ScriptRepository for DisabledScriptRepo {
|
||||
async fn get(&self, _id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
Ok(Some(self.script.clone()))
|
||||
}
|
||||
async fn get_by_name(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
_user_id: AdminUserId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn create(&self, _input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn update(
|
||||
&self,
|
||||
_id: ScriptId,
|
||||
_patch: ScriptPatch,
|
||||
) -> Result<Script, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn delete(&self, _id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn count_routes_for_script(
|
||||
&self,
|
||||
_script_id: ScriptId,
|
||||
) -> Result<i64, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn count_triggers_for_script(
|
||||
&self,
|
||||
_script_id: ScriptId,
|
||||
) -> Result<i64, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_imports(
|
||||
&self,
|
||||
_script_id: ScriptId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- QueueRepo: `claim` returns the message, `nack` records. ----
|
||||
struct ClaimNackQueue {
|
||||
claimed: ClaimedMessage,
|
||||
nacked: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl QueueRepo for ClaimNackQueue {
|
||||
async fn enqueue(
|
||||
&self,
|
||||
_msg: NewQueueMessage,
|
||||
) -> Result<picloud_shared::QueueMessageId, crate::queue_repo::QueueRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn claim(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
) -> Result<Option<ClaimedMessage>, crate::queue_repo::QueueRepoError> {
|
||||
Ok(Some(self.claimed.clone()))
|
||||
}
|
||||
async fn ack(
|
||||
&self,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
) -> Result<bool, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn nack(
|
||||
&self,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
_retry_delay: chrono::Duration,
|
||||
) -> Result<bool, crate::queue_repo::QueueRepoError> {
|
||||
self.nacked.store(true, Ordering::SeqCst);
|
||||
Ok(true)
|
||||
}
|
||||
async fn reclaim_visibility_timeouts(
|
||||
&self,
|
||||
) -> Result<u64, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn depth(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
) -> Result<u64, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn depth_pending(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
) -> Result<u64, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
) -> Result<Vec<(String, QueueStats)>, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn dead_letter(
|
||||
&self,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
_trigger_id: Option<picloud_shared::TriggerId>,
|
||||
_script_id: Option<ScriptId>,
|
||||
_attempt: u32,
|
||||
_first_attempt_at: chrono::DateTime<Utc>,
|
||||
_last_error: &str,
|
||||
) -> Result<picloud_shared::DeadLetterId, crate::queue_repo::QueueRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- PrincipalResolver: returns a valid Principal (regression). ----
|
||||
pub(super) struct OkPrincipals;
|
||||
|
||||
#[async_trait]
|
||||
impl PrincipalResolver for OkPrincipals {
|
||||
async fn resolve(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<Principal, PrincipalResolverError> {
|
||||
Ok(Principal {
|
||||
user_id,
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ExecutorClient: records execution then errors (regression). ----
|
||||
pub(super) struct RecordingExecutor {
|
||||
pub(super) executed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExecutorClient for RecordingExecutor {
|
||||
async fn execute(
|
||||
&self,
|
||||
_source: &str,
|
||||
_req: ExecRequest,
|
||||
_timeout: std::time::Duration,
|
||||
) -> Result<ExecResponse, ExecError> {
|
||||
self.executed.store(true, Ordering::SeqCst);
|
||||
Err(ExecError::Runtime("stub".into()))
|
||||
}
|
||||
// Intentionally NOT overriding `execute_with_identity`: the
|
||||
// default impl forwards to `execute`, which is what gate
|
||||
// removal would reach.
|
||||
}
|
||||
|
||||
// ---- Remaining deps: never exercised by the disabled path. ----
|
||||
pub(super) struct UnusedTriggers;
|
||||
|
||||
#[async_trait]
|
||||
impl TriggerRepo for UnusedTriggers {
|
||||
async fn create_kv_trigger(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_req: crate::trigger_repo::CreateKvTrigger,
|
||||
) -> Result<crate::trigger_repo::Trigger, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn create_docs_trigger(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_req: crate::trigger_repo::CreateDocsTrigger,
|
||||
) -> Result<crate::trigger_repo::Trigger, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn create_dead_letter_trigger(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_req: crate::trigger_repo::CreateDeadLetterTrigger,
|
||||
) -> Result<crate::trigger_repo::Trigger, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn create_cron_trigger(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_req: crate::trigger_repo::CreateCronTrigger,
|
||||
) -> Result<crate::trigger_repo::Trigger, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn create_files_trigger(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_req: crate::trigger_repo::CreateFilesTrigger,
|
||||
) -> Result<crate::trigger_repo::Trigger, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn create_pubsub_trigger(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_req: crate::trigger_repo::CreatePubsubTrigger,
|
||||
) -> Result<crate::trigger_repo::Trigger, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn create_email_trigger(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_req: crate::trigger_repo::CreateEmailTrigger,
|
||||
) -> Result<crate::trigger_repo::Trigger, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn email_inbound_target(
|
||||
&self,
|
||||
_trigger_id: picloud_shared::TriggerId,
|
||||
) -> Result<
|
||||
Option<crate::trigger_repo::EmailInboundTarget>,
|
||||
crate::trigger_repo::TriggerRepoError,
|
||||
> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
) -> Result<Vec<crate::trigger_repo::Trigger>, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
_id: picloud_shared::TriggerId,
|
||||
) -> Result<Option<crate::trigger_repo::Trigger>, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn delete(
|
||||
&self,
|
||||
_id: picloud_shared::TriggerId,
|
||||
) -> Result<bool, crate::trigger_repo::TriggerRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_matching_kv(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_collection: &str,
|
||||
_op: picloud_shared::KvEventOp,
|
||||
) -> Result<
|
||||
Vec<crate::trigger_repo::KvTriggerMatch>,
|
||||
crate::trigger_repo::TriggerRepoError,
|
||||
> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_matching_docs(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_collection: &str,
|
||||
_op: picloud_shared::DocsEventOp,
|
||||
) -> Result<
|
||||
Vec<crate::trigger_repo::DocsTriggerMatch>,
|
||||
crate::trigger_repo::TriggerRepoError,
|
||||
> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_matching_files(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_collection: &str,
|
||||
_op: picloud_shared::FilesEventOp,
|
||||
) -> Result<
|
||||
Vec<crate::trigger_repo::FilesTriggerMatch>,
|
||||
crate::trigger_repo::TriggerRepoError,
|
||||
> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_matching_dead_letter(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_source: &str,
|
||||
_trigger_id: Option<picloud_shared::TriggerId>,
|
||||
_script_id: Option<ScriptId>,
|
||||
) -> Result<
|
||||
Vec<crate::trigger_repo::DeadLetterTriggerMatch>,
|
||||
crate::trigger_repo::TriggerRepoError,
|
||||
> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn create_queue_trigger(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_req: crate::trigger_repo::CreateQueueTrigger,
|
||||
) -> Result<crate::trigger_repo::Trigger, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_active_queue_consumers(
|
||||
&self,
|
||||
) -> Result<Vec<ActiveQueueConsumer>, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn touch_queue_trigger_last_fired_at(
|
||||
&self,
|
||||
_trigger_id: picloud_shared::TriggerId,
|
||||
_at: chrono::DateTime<Utc>,
|
||||
) -> Result<(), crate::trigger_repo::TriggerRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct UnusedDeadLetters;
|
||||
|
||||
#[async_trait]
|
||||
impl DeadLetterRepo for UnusedDeadLetters {
|
||||
async fn insert(
|
||||
&self,
|
||||
_row: NewDeadLetter,
|
||||
) -> Result<picloud_shared::DeadLetterId, crate::dead_letter_repo::DeadLetterRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
_id: picloud_shared::DeadLetterId,
|
||||
) -> Result<
|
||||
Option<crate::dead_letter_repo::DeadLetterRow>,
|
||||
crate::dead_letter_repo::DeadLetterRepoError,
|
||||
> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_unresolved_only: bool,
|
||||
_limit: i64,
|
||||
_offset: i64,
|
||||
) -> Result<
|
||||
Vec<crate::dead_letter_repo::DeadLetterRow>,
|
||||
crate::dead_letter_repo::DeadLetterRepoError,
|
||||
> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn unresolved_count(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
) -> Result<i64, crate::dead_letter_repo::DeadLetterRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn resolve(
|
||||
&self,
|
||||
_id: picloud_shared::DeadLetterId,
|
||||
_reason: &str,
|
||||
) -> Result<(), crate::dead_letter_repo::DeadLetterRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn gc(
|
||||
&self,
|
||||
_older_than: chrono::DateTime<Utc>,
|
||||
_limit: i64,
|
||||
) -> Result<u64, crate::dead_letter_repo::DeadLetterRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
struct UnusedOutbox;
|
||||
|
||||
#[async_trait]
|
||||
impl OutboxRepo for UnusedOutbox {
|
||||
async fn insert(
|
||||
&self,
|
||||
_row: NewOutboxRow,
|
||||
) -> Result<Uuid, crate::outbox_repo::OutboxRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn claim_due(
|
||||
&self,
|
||||
_claimed_by: &str,
|
||||
_limit: i64,
|
||||
) -> Result<Vec<OutboxRow>, crate::outbox_repo::OutboxRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn delete(&self, _id: Uuid) -> Result<(), crate::outbox_repo::OutboxRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn reschedule(
|
||||
&self,
|
||||
_id: Uuid,
|
||||
_attempt_count: u32,
|
||||
_next_attempt_at: chrono::DateTime<Utc>,
|
||||
) -> Result<(), crate::outbox_repo::OutboxRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct UnusedAbandoned;
|
||||
|
||||
#[async_trait]
|
||||
impl AbandonedRepo for UnusedAbandoned {
|
||||
async fn insert(
|
||||
&self,
|
||||
_row: NewAbandonedExecution,
|
||||
) -> Result<Uuid, crate::abandoned_repo::AbandonedRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn gc(
|
||||
&self,
|
||||
_older_than: chrono::DateTime<Utc>,
|
||||
_limit: i64,
|
||||
) -> Result<u64, crate::abandoned_repo::AbandonedRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct UnusedLogSink;
|
||||
|
||||
#[async_trait]
|
||||
impl ExecutionLogSink for UnusedLogSink {
|
||||
async fn record(
|
||||
&self,
|
||||
_log: picloud_shared::ExecutionLog,
|
||||
) -> Result<(), picloud_shared::LogSinkError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct UnusedInbox;
|
||||
|
||||
#[async_trait]
|
||||
impl InboxResolver for UnusedInbox {
|
||||
async fn deliver(
|
||||
&self,
|
||||
_inbox_id: Uuid,
|
||||
_result: picloud_shared::InboxResult,
|
||||
) -> picloud_shared::InboxDeliveryOutcome {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn disabled_script(app_id: AppId) -> Script {
|
||||
Script {
|
||||
id: ScriptId::new(),
|
||||
app_id,
|
||||
name: "worker".into(),
|
||||
description: None,
|
||||
version: 1,
|
||||
source: "0".into(),
|
||||
kind: picloud_shared::ScriptKind::Endpoint,
|
||||
timeout_seconds: 30,
|
||||
memory_limit_mb: 64,
|
||||
sandbox: ScriptSandbox::default(),
|
||||
enabled: false,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_queue_consumer_releases_claim_without_executing() {
|
||||
let app_id = AppId::new();
|
||||
let script = disabled_script(app_id);
|
||||
|
||||
let claimed = ClaimedMessage {
|
||||
id: picloud_shared::QueueMessageId::new(),
|
||||
app_id,
|
||||
queue_name: "jobs".into(),
|
||||
payload: serde_json::Value::Null,
|
||||
enqueued_at: Utc::now(),
|
||||
attempt: 1,
|
||||
max_attempts: 5,
|
||||
claim_token: Uuid::new_v4(),
|
||||
};
|
||||
|
||||
let consumer = ActiveQueueConsumer {
|
||||
trigger_id: picloud_shared::TriggerId::new(),
|
||||
app_id,
|
||||
script_id: script.id,
|
||||
queue_name: "jobs".into(),
|
||||
visibility_timeout_secs: 30,
|
||||
retry_max_attempts: 5,
|
||||
retry_backoff: BackoffShape::Exponential,
|
||||
retry_base_ms: 1000,
|
||||
registered_by_principal: AdminUserId::new(),
|
||||
};
|
||||
|
||||
let nacked = Arc::new(AtomicBool::new(false));
|
||||
let executed = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let dispatcher = Dispatcher {
|
||||
outbox: Arc::new(UnusedOutbox),
|
||||
triggers: Arc::new(UnusedTriggers),
|
||||
scripts: Arc::new(DisabledScriptRepo {
|
||||
script: script.clone(),
|
||||
}),
|
||||
dead_letters: Arc::new(UnusedDeadLetters),
|
||||
abandoned: Arc::new(UnusedAbandoned),
|
||||
principals: Arc::new(OkPrincipals),
|
||||
executor: Arc::new(RecordingExecutor {
|
||||
executed: executed.clone(),
|
||||
}),
|
||||
gate: Arc::new(ExecutionGate::new(1)),
|
||||
log_sink: Arc::new(UnusedLogSink),
|
||||
inbox: Arc::new(UnusedInbox),
|
||||
queue: Arc::new(ClaimNackQueue {
|
||||
claimed,
|
||||
nacked: nacked.clone(),
|
||||
}),
|
||||
config: TriggerConfig::from_env(),
|
||||
instance_id: "test-instance".into(),
|
||||
};
|
||||
|
||||
let result = dispatcher.dispatch_one_queue(&consumer).await;
|
||||
|
||||
// 1. The disabled path returns Ok(()).
|
||||
assert!(result.is_ok(), "dispatch_one_queue returned {result:?}");
|
||||
// 2. The claim was released via nack.
|
||||
assert!(
|
||||
nacked.load(Ordering::SeqCst),
|
||||
"expected nack to release the claim for a disabled consumer"
|
||||
);
|
||||
// 3. The executor was never reached. If the `if !script.enabled`
|
||||
// gate is deleted, the flow falls through to resolve →
|
||||
// execute and this flips to true.
|
||||
assert!(
|
||||
!executed.load(Ordering::SeqCst),
|
||||
"executor ran for a disabled queue consumer; fire-time gate missing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// OUTBOX (async-HTTP) arm fire-time `enabled` gate (§4.3).
|
||||
//
|
||||
// `dispatch_one` builds an `ExecRequest` from an HTTP outbox row via
|
||||
// `build_http_request`, which sets `resolved.active = script.enabled`
|
||||
// but does NOT itself reject a disabled script. The unified gate at
|
||||
// `if !resolved.active` then drops the row (delete) without executing.
|
||||
// This test proves that gate end-to-end for an HTTP-source row whose
|
||||
// target script was disabled after the row was enqueued.
|
||||
//
|
||||
// Regression property (mirrors the queue test): `RecordingExecutor`
|
||||
// flips `executed = true` before erroring, so DELETING the
|
||||
// `if !resolved.active` gate makes the flow fall through to execute,
|
||||
// flipping `executed` and failing `assert!(!executed)`.
|
||||
// ----------------------------------------------------------------
|
||||
mod outbox_enabled_gate {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use picloud_orchestrator_core::ExecutionGate;
|
||||
use picloud_shared::AppId;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxRow, OutboxSourceKind};
|
||||
|
||||
// Reuse the stub trait impls + helpers from the queue test so the
|
||||
// two gate tests share one set of in-memory deps.
|
||||
use super::queue_enabled_gate::{
|
||||
disabled_script, DisabledScriptRepo, OkPrincipals, RecordingExecutor, UnusedAbandoned,
|
||||
UnusedDeadLetters, UnusedInbox, UnusedLogSink, UnusedTriggers,
|
||||
};
|
||||
|
||||
// QueueRepo is never reached on the outbox arm — a panic stub keeps
|
||||
// the surface honest without pulling the queue test's claim stub.
|
||||
struct UnusedQueue;
|
||||
|
||||
#[async_trait]
|
||||
impl crate::queue_repo::QueueRepo for UnusedQueue {
|
||||
async fn enqueue(
|
||||
&self,
|
||||
_msg: crate::queue_repo::NewQueueMessage,
|
||||
) -> Result<picloud_shared::QueueMessageId, crate::queue_repo::QueueRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn claim(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
) -> Result<Option<crate::queue_repo::ClaimedMessage>, crate::queue_repo::QueueRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn ack(
|
||||
&self,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
) -> Result<bool, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn nack(
|
||||
&self,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
_retry_delay: chrono::Duration,
|
||||
) -> Result<bool, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn reclaim_visibility_timeouts(
|
||||
&self,
|
||||
) -> Result<u64, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn depth(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
) -> Result<u64, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn depth_pending(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
) -> Result<u64, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
) -> Result<
|
||||
Vec<(String, crate::queue_repo::QueueStats)>,
|
||||
crate::queue_repo::QueueRepoError,
|
||||
> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn dead_letter(
|
||||
&self,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
_trigger_id: Option<picloud_shared::TriggerId>,
|
||||
_script_id: Option<picloud_shared::ScriptId>,
|
||||
_attempt: u32,
|
||||
_first_attempt_at: chrono::DateTime<Utc>,
|
||||
_last_error: &str,
|
||||
) -> Result<picloud_shared::DeadLetterId, crate::queue_repo::QueueRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
// OutboxRepo whose `delete` records the id it was called with; the
|
||||
// other methods are never reached on the disabled-drop path.
|
||||
struct RecordingOutbox {
|
||||
deleted: Arc<Mutex<Option<Uuid>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OutboxRepo for RecordingOutbox {
|
||||
async fn insert(
|
||||
&self,
|
||||
_row: NewOutboxRow,
|
||||
) -> Result<Uuid, crate::outbox_repo::OutboxRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn claim_due(
|
||||
&self,
|
||||
_claimed_by: &str,
|
||||
_limit: i64,
|
||||
) -> Result<Vec<OutboxRow>, crate::outbox_repo::OutboxRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn delete(&self, id: Uuid) -> Result<(), crate::outbox_repo::OutboxRepoError> {
|
||||
*self.deleted.lock().unwrap() = Some(id);
|
||||
Ok(())
|
||||
}
|
||||
async fn reschedule(
|
||||
&self,
|
||||
_id: Uuid,
|
||||
_attempt_count: u32,
|
||||
_next_attempt_at: chrono::DateTime<Utc>,
|
||||
) -> Result<(), crate::outbox_repo::OutboxRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_http_outbox_row_is_dropped_without_executing() {
|
||||
let app_id = AppId::new();
|
||||
let script = disabled_script(app_id);
|
||||
|
||||
// Minimal valid HttpDispatchPayload (see shared::outbox_writer).
|
||||
let payload = serde_json::json!({
|
||||
"script_name": "worker",
|
||||
"path": "/hook",
|
||||
"method": "POST",
|
||||
"headers": {},
|
||||
"body": null,
|
||||
"params": {},
|
||||
"query": {},
|
||||
"rest": "",
|
||||
"timeout_seconds": 30,
|
||||
});
|
||||
|
||||
let row_id = Uuid::new_v4();
|
||||
let row = OutboxRow {
|
||||
id: row_id,
|
||||
// Same app_id as the script so the same-app guard passes.
|
||||
app_id,
|
||||
source_kind: OutboxSourceKind::Http,
|
||||
trigger_id: None,
|
||||
script_id: Some(script.id),
|
||||
reply_to: None,
|
||||
payload,
|
||||
origin_principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: None,
|
||||
attempt_count: 0,
|
||||
next_attempt_at: Utc::now(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let deleted = Arc::new(Mutex::new(None));
|
||||
let executed = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let dispatcher = Dispatcher {
|
||||
outbox: Arc::new(RecordingOutbox {
|
||||
deleted: deleted.clone(),
|
||||
}),
|
||||
triggers: Arc::new(UnusedTriggers),
|
||||
scripts: Arc::new(DisabledScriptRepo {
|
||||
script: script.clone(),
|
||||
}),
|
||||
dead_letters: Arc::new(UnusedDeadLetters),
|
||||
abandoned: Arc::new(UnusedAbandoned),
|
||||
principals: Arc::new(OkPrincipals),
|
||||
executor: Arc::new(RecordingExecutor {
|
||||
executed: executed.clone(),
|
||||
}),
|
||||
gate: Arc::new(ExecutionGate::new(1)),
|
||||
log_sink: Arc::new(UnusedLogSink),
|
||||
inbox: Arc::new(UnusedInbox),
|
||||
queue: Arc::new(UnusedQueue),
|
||||
config: TriggerConfig::from_env(),
|
||||
instance_id: "test-instance".into(),
|
||||
};
|
||||
|
||||
let result = dispatcher.dispatch_one(row).await;
|
||||
|
||||
// 1. The disabled-drop path returns Ok(()).
|
||||
assert!(result.is_ok(), "dispatch_one returned {result:?}");
|
||||
// 2. The outbox row was deleted with its own id.
|
||||
assert_eq!(
|
||||
*deleted.lock().unwrap(),
|
||||
Some(row_id),
|
||||
"expected the disabled HTTP outbox row to be deleted"
|
||||
);
|
||||
// 3. The executor was never reached. If the `if !resolved.active`
|
||||
// gate at dispatch_one is deleted, the flow falls through to
|
||||
// execute and this flips to true.
|
||||
assert!(
|
||||
!executed.load(Ordering::SeqCst),
|
||||
"executor ran for a disabled HTTP outbox row; fire-time gate missing"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +341,10 @@ impl DevEmailSink {
|
||||
}
|
||||
|
||||
fn push(&self, email: CapturedEmail) {
|
||||
let mut q = self.captured.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut q = self
|
||||
.captured
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
while q.len() >= self.capacity {
|
||||
q.pop_front();
|
||||
}
|
||||
@@ -351,7 +354,10 @@ impl DevEmailSink {
|
||||
/// Newest-first snapshot of the captured mail.
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> Vec<CapturedEmail> {
|
||||
let q = self.captured.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let q = self
|
||||
.captured
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
q.iter().rev().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
205
crates/manager-core/src/group_members_repo.rs
Normal file
205
crates/manager-core/src/group_members_repo.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
//! CRUD over the `group_members` table — explicit per-(user, group) role
|
||||
//! grants. A `group_admin` here is implicitly app_admin on every app and
|
||||
//! subgroup beneath the group; that inheritance is resolved by the authz
|
||||
//! ancestor walk (`app_members_repo::effective_app_role`), not here.
|
||||
//!
|
||||
//! Mirrors `app_members_repo` — same three role literals, same shapes.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, AppRole, GroupId, InstanceRole};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupMembersRepositoryError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
#[error("invalid app_role stored in DB: {0}")]
|
||||
InvalidRole(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GroupMembershipRow {
|
||||
pub group_id: GroupId,
|
||||
pub user_id: AdminUserId,
|
||||
pub role: AppRole,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// `group_members` row joined with `admin_users` for the dashboard's
|
||||
/// per-group Members tab.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GroupMembershipDetail {
|
||||
pub user_id: AdminUserId,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub instance_role: InstanceRole,
|
||||
pub is_active: bool,
|
||||
pub role: AppRole,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupMembersRepository: Send + Sync {
|
||||
/// Atomic insert. `None` if a membership already exists (handler → 409).
|
||||
async fn try_insert(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError>;
|
||||
|
||||
/// Atomic role update. `None` if no row exists (handler → 404).
|
||||
async fn update_role(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError>;
|
||||
|
||||
/// Remove a membership. No-op when the row doesn't exist.
|
||||
async fn remove(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<(), GroupMembersRepositoryError>;
|
||||
|
||||
/// Per-group member list joined with `admin_users`, ordered by username.
|
||||
async fn list_for_group_enriched(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<GroupMembershipDetail>, GroupMembersRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresGroupMembersRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresGroupMembersRepository {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupMembersRepository for PostgresGroupMembersRepository {
|
||||
async fn try_insert(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError> {
|
||||
let row = sqlx::query_as::<_, GroupMembershipRecord>(
|
||||
"INSERT INTO group_members (group_id, user_id, role) \
|
||||
VALUES ($1, $2, $3) \
|
||||
ON CONFLICT (group_id, user_id) DO NOTHING \
|
||||
RETURNING group_id, user_id, role, created_at",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(user_id.into_inner())
|
||||
.bind(role.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn update_role(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError> {
|
||||
let row = sqlx::query_as::<_, GroupMembershipRecord>(
|
||||
"UPDATE group_members SET role = $1 \
|
||||
WHERE group_id = $2 AND user_id = $3 \
|
||||
RETURNING group_id, user_id, role, created_at",
|
||||
)
|
||||
.bind(role.as_str())
|
||||
.bind(group_id.into_inner())
|
||||
.bind(user_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn remove(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<(), GroupMembersRepositoryError> {
|
||||
sqlx::query("DELETE FROM group_members WHERE group_id = $1 AND user_id = $2")
|
||||
.bind(group_id.into_inner())
|
||||
.bind(user_id.into_inner())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_for_group_enriched(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<GroupMembershipDetail>, GroupMembersRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, GroupMembershipDetailRecord>(
|
||||
"SELECT au.id, au.username, au.email, au.instance_role, au.is_active, \
|
||||
gm.role, gm.created_at \
|
||||
FROM group_members gm \
|
||||
JOIN admin_users au ON au.id = gm.user_id \
|
||||
WHERE gm.group_id = $1 \
|
||||
ORDER BY au.username",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct GroupMembershipRecord {
|
||||
group_id: uuid::Uuid,
|
||||
user_id: uuid::Uuid,
|
||||
role: String,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<GroupMembershipRecord> for GroupMembershipRow {
|
||||
type Error = GroupMembersRepositoryError;
|
||||
fn try_from(r: GroupMembershipRecord) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
group_id: r.group_id.into(),
|
||||
user_id: r.user_id.into(),
|
||||
role: AppRole::from_db_str(&r.role)
|
||||
.ok_or(GroupMembersRepositoryError::InvalidRole(r.role))?,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct GroupMembershipDetailRecord {
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
email: Option<String>,
|
||||
instance_role: String,
|
||||
is_active: bool,
|
||||
role: String,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<GroupMembershipDetailRecord> for GroupMembershipDetail {
|
||||
type Error = GroupMembersRepositoryError;
|
||||
fn try_from(r: GroupMembershipDetailRecord) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
user_id: r.id.into(),
|
||||
username: r.username,
|
||||
email: r.email,
|
||||
instance_role: InstanceRole::from_db_str(&r.instance_role)
|
||||
.ok_or(GroupMembersRepositoryError::InvalidRole(r.instance_role))?,
|
||||
is_active: r.is_active,
|
||||
role: AppRole::from_db_str(&r.role)
|
||||
.ok_or(GroupMembersRepositoryError::InvalidRole(r.role))?,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
367
crates/manager-core/src/group_repo.rs
Normal file
367
crates/manager-core/src/group_repo.rs
Normal file
@@ -0,0 +1,367 @@
|
||||
//! CRUD over the `groups` tree (Phase 2).
|
||||
//!
|
||||
//! Groups form a single-parent org tree above apps. Structural mutations
|
||||
//! (reparent/rename/delete) must keep the tree acyclic and non-orphaning:
|
||||
//!
|
||||
//! - **delete = RESTRICT** — refused if the group has child groups or apps
|
||||
//! (the DB FKs enforce this; we surface a clean conflict).
|
||||
//! - **slug-freeze** — `rename` edits name/description only; the slug is
|
||||
//! set once at creation and never rewritten.
|
||||
//! - **cycle guard** — `reparent` walks the destination's ancestors under a
|
||||
//! coarse instance-wide advisory lock and refuses a move that would make
|
||||
//! a node its own ancestor. A SQL `CHECK` can't express this.
|
||||
//! - **structure_version** — bumped on every structural mutation so a
|
||||
//! future CLI/orchestrator can detect structural drift (§6).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{Group, GroupId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Instance-wide advisory-lock key for structural group mutations. Coarse
|
||||
/// on purpose: reparent/rename/delete all take it so the ancestor-walk
|
||||
/// cycle guard and the `parent_id` write run serialized — two concurrent
|
||||
/// reparents can't race into a cycle. Distinct from the per-app
|
||||
/// `apply_lock_key` space (a fixed sentinel, hashed-namespace-free).
|
||||
const GROUP_STRUCTURAL_LOCK_KEY: i64 = 0x6701_0047_0000_0001;
|
||||
|
||||
/// Well-known slug of the instance root group seeded by migration 0047.
|
||||
pub const ROOT_GROUP_SLUG: &str = "root";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupRepositoryError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
#[error("not found: {0}")]
|
||||
NotFound(GroupId),
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
}
|
||||
|
||||
/// Counts of a group's direct children — used to enforce delete=RESTRICT
|
||||
/// with an actionable message and to render the tree.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct GroupChildCounts {
|
||||
pub subgroups: i64,
|
||||
pub apps: i64,
|
||||
}
|
||||
|
||||
impl GroupChildCounts {
|
||||
#[must_use]
|
||||
pub fn is_empty(self) -> bool {
|
||||
self.subgroups == 0 && self.apps == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupRepository: Send + Sync {
|
||||
/// Every group on the instance, ordered by name. The tree is small
|
||||
/// (org structure), so callers assemble the hierarchy in memory.
|
||||
async fn list(&self) -> Result<Vec<Group>, GroupRepositoryError>;
|
||||
async fn get_by_id(&self, id: GroupId) -> Result<Option<Group>, GroupRepositoryError>;
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<Group>, GroupRepositoryError>;
|
||||
/// Direct children groups of `parent`.
|
||||
async fn list_children(&self, parent: GroupId) -> Result<Vec<Group>, GroupRepositoryError>;
|
||||
/// The node plus its ancestors up to the root, nearest-first. Used for
|
||||
/// path display and as the reparent cycle-guard input.
|
||||
async fn ancestors(&self, id: GroupId) -> Result<Vec<Group>, GroupRepositoryError>;
|
||||
async fn child_counts(&self, id: GroupId) -> Result<GroupChildCounts, GroupRepositoryError>;
|
||||
async fn create(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
parent_id: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError>;
|
||||
/// Edit display fields only — the slug is frozen at creation. Bumps
|
||||
/// `structure_version`.
|
||||
async fn rename(
|
||||
&self,
|
||||
id: GroupId,
|
||||
name: Option<&str>,
|
||||
description: Option<Option<&str>>,
|
||||
) -> Result<Group, GroupRepositoryError>;
|
||||
/// Move `id` under `new_parent` (or to root if `None`). Runs the
|
||||
/// ancestor-walk cycle guard under a coarse structural lock and bumps
|
||||
/// `structure_version`. Refuses a move that would create a cycle.
|
||||
async fn reparent(
|
||||
&self,
|
||||
id: GroupId,
|
||||
new_parent: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError>;
|
||||
/// Delete an empty group (delete = RESTRICT). Refused with a clean
|
||||
/// conflict if it still has child groups or apps.
|
||||
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresGroupRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresGroupRepository {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
const GROUP_COLS: &str =
|
||||
"id, parent_id, slug, name, description, structure_version, created_at, updated_at";
|
||||
|
||||
#[async_trait]
|
||||
impl GroupRepository for PostgresGroupRepository {
|
||||
async fn list(&self) -> Result<Vec<Group>, GroupRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"SELECT {GROUP_COLS} FROM groups ORDER BY name"
|
||||
))
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: GroupId) -> Result<Option<Group>, GroupRepositoryError> {
|
||||
let row = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"SELECT {GROUP_COLS} FROM groups WHERE id = $1"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<Group>, GroupRepositoryError> {
|
||||
let row = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"SELECT {GROUP_COLS} FROM groups WHERE slug = $1"
|
||||
))
|
||||
.bind(slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn list_children(&self, parent: GroupId) -> Result<Vec<Group>, GroupRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"SELECT {GROUP_COLS} FROM groups WHERE parent_id = $1 ORDER BY name"
|
||||
))
|
||||
.bind(parent.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn ancestors(&self, id: GroupId) -> Result<Vec<Group>, GroupRepositoryError> {
|
||||
// Recursive walk node → root, nearest-first. Depth-bounded as a
|
||||
// runaway guard (the cycle guard already prevents cycles).
|
||||
let rows = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"WITH RECURSIVE chain AS (
|
||||
SELECT {GROUP_COLS}, 0 AS depth FROM groups WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT g.id, g.parent_id, g.slug, g.name, g.description, \
|
||||
g.structure_version, g.created_at, g.updated_at, c.depth + 1 \
|
||||
FROM groups g JOIN chain c ON g.id = c.parent_id \
|
||||
WHERE c.depth < 64
|
||||
)
|
||||
SELECT {GROUP_COLS} FROM chain ORDER BY depth"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn child_counts(&self, id: GroupId) -> Result<GroupChildCounts, GroupRepositoryError> {
|
||||
let row: (i64, i64) = sqlx::query_as(
|
||||
"SELECT \
|
||||
(SELECT COUNT(*) FROM groups WHERE parent_id = $1), \
|
||||
(SELECT COUNT(*) FROM apps WHERE group_id = $1)",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(GroupChildCounts {
|
||||
subgroups: row.0,
|
||||
apps: row.1,
|
||||
})
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
parent_id: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
let res = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"INSERT INTO groups (slug, name, description, parent_id) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(parent_id.map(GroupId::into_inner))
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
|
||||
),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
|
||||
GroupRepositoryError::Conflict("parent group does not exist".into()),
|
||||
),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn rename(
|
||||
&self,
|
||||
id: GroupId,
|
||||
name: Option<&str>,
|
||||
description: Option<Option<&str>>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
// Slug is intentionally absent from the SET list — it is frozen.
|
||||
let row = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"UPDATE groups SET \
|
||||
name = COALESCE($2, name), \
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
structure_version = structure_version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.bind(name)
|
||||
.bind(description.is_some())
|
||||
.bind(description.and_then(|d| d))
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(Into::into)
|
||||
.ok_or(GroupRepositoryError::NotFound(id))
|
||||
}
|
||||
|
||||
async fn reparent(
|
||||
&self,
|
||||
id: GroupId,
|
||||
new_parent: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
// Coarse structural lock: serialize all structural mutations so the
|
||||
// cycle guard + parent write can't interleave with a concurrent
|
||||
// reparent and race into a cycle.
|
||||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||
.bind(GROUP_STRUCTURAL_LOCK_KEY)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(parent) = new_parent {
|
||||
if parent == id {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"a group cannot be its own parent".into(),
|
||||
));
|
||||
}
|
||||
// Cycle guard: walk from the destination up to the root; if we
|
||||
// reach `id`, the move would place `id` beneath itself.
|
||||
let mut cursor = Some(parent);
|
||||
let mut hops = 0u32;
|
||||
while let Some(node) = cursor {
|
||||
if node == id {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"cannot reparent a group beneath one of its own descendants".into(),
|
||||
));
|
||||
}
|
||||
hops += 1;
|
||||
if hops > 64 {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"group ancestry exceeds the maximum depth".into(),
|
||||
));
|
||||
}
|
||||
let parent_of: Option<(Option<Uuid>,)> =
|
||||
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
|
||||
.bind(node.into_inner())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
match parent_of {
|
||||
Some((p,)) => cursor = p.map(GroupId::from),
|
||||
// Destination parent doesn't exist.
|
||||
None => {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"destination parent group does not exist".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"UPDATE groups SET \
|
||||
parent_id = $2, \
|
||||
structure_version = structure_version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.bind(new_parent.map(GroupId::into_inner))
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(GroupRepositoryError::NotFound(id));
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError> {
|
||||
// Pre-check for a clean message; the FK RESTRICT is the real guard.
|
||||
let counts = self.child_counts(id).await?;
|
||||
if !counts.is_empty() {
|
||||
return Err(GroupRepositoryError::Conflict(format!(
|
||||
"group still has {} subgroup(s) and {} app(s); move or delete them first",
|
||||
counts.subgroups, counts.apps
|
||||
)));
|
||||
}
|
||||
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
match res {
|
||||
Ok(r) if r.rows_affected() == 0 => Err(GroupRepositoryError::NotFound(id)),
|
||||
Ok(_) => Ok(()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
// Lost a race with a concurrent child insert.
|
||||
Err(GroupRepositoryError::Conflict(
|
||||
"group still has descendants; move or delete them first".into(),
|
||||
))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct GroupRow {
|
||||
id: Uuid,
|
||||
parent_id: Option<Uuid>,
|
||||
slug: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
structure_version: i64,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<GroupRow> for Group {
|
||||
fn from(r: GroupRow) -> Self {
|
||||
Self {
|
||||
id: r.id.into(),
|
||||
parent_id: r.parent_id.map(Into::into),
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
structure_version: r.structure_version,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
577
crates/manager-core/src/groups_api.rs
Normal file
577
crates/manager-core/src/groups_api.rs
Normal file
@@ -0,0 +1,577 @@
|
||||
//! `/api/v1/admin/groups/*` — CRUD over the group tree + per-group
|
||||
//! membership (Phase 2, blueprint §5).
|
||||
//!
|
||||
//! Group capabilities resolve by walking the group's ancestor chain
|
||||
//! (`authz::effective_group_role`): a `group_admin` on any ancestor is
|
||||
//! implicitly admin of every descendant group. Structural mutations
|
||||
//! (reparent/delete) are gated on `GroupAdmin`; reparent additionally
|
||||
//! requires admin at BOTH the source and destination parent (§5.6).
|
||||
//!
|
||||
//! Slug is frozen at creation — PATCH edits name/description only.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, patch, post};
|
||||
use axum::{Extension, Router};
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, App, AppRole, Group, InstanceRole, Principal};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::admin_user_repo::{AdminUserRepository, AdminUserRow};
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::group_members_repo::{
|
||||
GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
|
||||
};
|
||||
use crate::group_repo::{GroupRepository, GroupRepositoryError};
|
||||
|
||||
const SLUG_MAX: usize = 63;
|
||||
const RESERVED_SLUGS: &[&str] = &[
|
||||
"new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps", "groups",
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GroupsState {
|
||||
pub groups: Arc<dyn GroupRepository>,
|
||||
pub group_members: Arc<dyn GroupMembersRepository>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub users: Arc<dyn AdminUserRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn groups_router(state: GroupsState) -> Router {
|
||||
Router::new()
|
||||
.route("/groups", get(list_groups).post(create_group))
|
||||
.route(
|
||||
"/groups/{id_or_slug}",
|
||||
get(get_group).patch(patch_group).delete(delete_group),
|
||||
)
|
||||
.route("/groups/{id_or_slug}/reparent", post(reparent_group))
|
||||
.route(
|
||||
"/groups/{id_or_slug}/members",
|
||||
get(list_members).post(grant_member),
|
||||
)
|
||||
.route(
|
||||
"/groups/{id_or_slug}/members/{user_id}",
|
||||
patch(patch_member).delete(remove_member),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GroupDetailDto {
|
||||
#[serde(flatten)]
|
||||
pub group: Group,
|
||||
/// Root → … → this group (nearest-last), for breadcrumb display.
|
||||
pub path: Vec<Group>,
|
||||
pub subgroups: Vec<Group>,
|
||||
pub apps: Vec<App>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateGroupRequest {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Parent group (slug or id). Omitted ⇒ a root-level group
|
||||
/// (owner/admin only).
|
||||
#[serde(default)]
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PatchGroupRequest {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReparentRequest {
|
||||
/// New parent (slug or id). Omitted/null ⇒ move to root.
|
||||
#[serde(default)]
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GroupMemberDto {
|
||||
pub user_id: AdminUserId,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub instance_role: InstanceRole,
|
||||
pub is_active: bool,
|
||||
pub role: AppRole,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<GroupMembershipDetail> for GroupMemberDto {
|
||||
fn from(d: GroupMembershipDetail) -> Self {
|
||||
Self {
|
||||
user_id: d.user_id,
|
||||
username: d.username,
|
||||
email: d.email,
|
||||
instance_role: d.instance_role,
|
||||
is_active: d.is_active,
|
||||
role: d.role,
|
||||
created_at: d.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GrantMemberRequest {
|
||||
pub user_id: AdminUserId,
|
||||
pub role: AppRole,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PatchMemberRequest {
|
||||
pub role: AppRole,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Group CRUD handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// List the whole tree. Phase-2 simplification: group names/structure are
|
||||
/// low-sensitivity org metadata (groups own no resources/secrets yet), so
|
||||
/// any authenticated admin sees the full tree; per-action authz still
|
||||
/// gates every mutation and all app access. Tighten in Phase 3 when groups
|
||||
/// carry inheritable config.
|
||||
async fn list_groups(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(_principal): Extension<Principal>,
|
||||
) -> Result<Json<Vec<Group>>, GroupsApiError> {
|
||||
Ok(Json(s.groups.list().await?))
|
||||
}
|
||||
|
||||
async fn create_group(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<CreateGroupRequest>,
|
||||
) -> Result<(StatusCode, Json<Group>), GroupsApiError> {
|
||||
validate_slug(&input.slug)?;
|
||||
|
||||
let parent_id = match input.parent.as_deref() {
|
||||
// Root-level group — an instance act.
|
||||
None => {
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceCreateGroup,
|
||||
)
|
||||
.await?;
|
||||
None
|
||||
}
|
||||
// Subgroup — requires group-admin at the parent.
|
||||
Some(ident) => {
|
||||
let parent = resolve_group(&*s.groups, ident).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(parent.id),
|
||||
)
|
||||
.await?;
|
||||
Some(parent.id)
|
||||
}
|
||||
};
|
||||
|
||||
let created = s
|
||||
.groups
|
||||
.create(
|
||||
&input.slug,
|
||||
&input.name,
|
||||
input.description.as_deref(),
|
||||
parent_id,
|
||||
)
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn get_group(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<GroupDetailDto>, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupRead(group.id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ancestors() is nearest-first incl. the node; reverse for a
|
||||
// root→…→node breadcrumb.
|
||||
let mut path = s.groups.ancestors(group.id).await?;
|
||||
path.reverse();
|
||||
let subgroups = s.groups.list_children(group.id).await?;
|
||||
let apps = s.apps.list_for_group(group.id).await?;
|
||||
Ok(Json(GroupDetailDto {
|
||||
group,
|
||||
path,
|
||||
subgroups,
|
||||
apps,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn patch_group(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<PatchGroupRequest>,
|
||||
) -> Result<Json<Group>, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupWrite(group.id),
|
||||
)
|
||||
.await?;
|
||||
let updated = s
|
||||
.groups
|
||||
.rename(
|
||||
group.id,
|
||||
input.name.as_deref(),
|
||||
input.description.as_ref().map(|d| Some(d.as_str())),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(updated))
|
||||
}
|
||||
|
||||
async fn reparent_group(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<ReparentRequest>,
|
||||
) -> Result<Json<Group>, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
// Admin of the node being moved.
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
// Admin at the SOURCE parent (you're removing it from that domain).
|
||||
if let Some(src) = group.parent_id {
|
||||
require(s.authz.as_ref(), &principal, Capability::GroupAdmin(src)).await?;
|
||||
}
|
||||
// Resolve + require admin at the DESTINATION parent.
|
||||
let new_parent = match input.parent.as_deref() {
|
||||
None => None,
|
||||
Some(ident) => {
|
||||
let dest = resolve_group(&*s.groups, ident).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(dest.id),
|
||||
)
|
||||
.await?;
|
||||
Some(dest.id)
|
||||
}
|
||||
};
|
||||
let moved = s.groups.reparent(group.id, new_parent).await?;
|
||||
Ok(Json(moved))
|
||||
}
|
||||
|
||||
async fn delete_group(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<StatusCode, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
s.groups.delete(group.id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Member handlers — gated on GroupAdmin(group)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_members(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<GroupMemberDto>>, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
let rows = s.group_members.list_for_group_enriched(group.id).await?;
|
||||
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||
}
|
||||
|
||||
async fn grant_member(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<GrantMemberRequest>,
|
||||
) -> Result<(StatusCode, Json<GroupMemberDto>), GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let user = s
|
||||
.users
|
||||
.get(input.user_id)
|
||||
.await?
|
||||
.ok_or(GroupsApiError::UserNotFound(input.user_id))?;
|
||||
validate_grant_target(&user)?;
|
||||
|
||||
let row = s
|
||||
.group_members
|
||||
.try_insert(group.id, user.id, input.role)
|
||||
.await?
|
||||
.ok_or_else(|| GroupsApiError::AlreadyMember {
|
||||
username: user.username.clone(),
|
||||
})?;
|
||||
Ok((StatusCode::CREATED, Json(compose_dto(user, row))))
|
||||
}
|
||||
|
||||
async fn patch_member(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
||||
Json(input): Json<PatchMemberRequest>,
|
||||
) -> Result<Json<GroupMemberDto>, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let user_id = AdminUserId::from(user_id);
|
||||
let user = s
|
||||
.users
|
||||
.get(user_id)
|
||||
.await?
|
||||
.ok_or(GroupsApiError::UserNotFound(user_id))?;
|
||||
let row = s
|
||||
.group_members
|
||||
.update_role(group.id, user_id, input.role)
|
||||
.await?
|
||||
.ok_or(GroupsApiError::MembershipNotFound)?;
|
||||
Ok(Json(compose_dto(user, row)))
|
||||
}
|
||||
|
||||
async fn remove_member(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
||||
) -> Result<StatusCode, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
s.group_members
|
||||
.remove(group.id, AdminUserId::from(user_id))
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Validation + helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Resolve a group identifier (slug or UUID) to a `Group`.
|
||||
async fn resolve_group(groups: &dyn GroupRepository, ident: &str) -> Result<Group, GroupsApiError> {
|
||||
let found = if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||
groups.get_by_id(uuid.into()).await?
|
||||
} else {
|
||||
groups.get_by_slug(ident).await?
|
||||
};
|
||||
found.ok_or_else(|| GroupsApiError::GroupNotFound(ident.to_string()))
|
||||
}
|
||||
|
||||
/// Same rule as app slugs: `^[a-z0-9][a-z0-9-]{0,62}$`, no reserved words.
|
||||
fn validate_slug(slug: &str) -> Result<(), GroupsApiError> {
|
||||
let invalid = |reason: &str| GroupsApiError::InvalidSlug(format!("{slug:?}: {reason}"));
|
||||
if slug.is_empty() || slug.len() > SLUG_MAX {
|
||||
return Err(invalid("must be 1–63 characters"));
|
||||
}
|
||||
let mut chars = slug.chars();
|
||||
let first = chars.next().unwrap();
|
||||
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||
return Err(invalid("must start with a lowercase letter or digit"));
|
||||
}
|
||||
if !slug
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(invalid(
|
||||
"may contain only lowercase letters, digits, and hyphens",
|
||||
));
|
||||
}
|
||||
if RESERVED_SLUGS.contains(&slug) {
|
||||
return Err(invalid("is a reserved word"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_grant_target(user: &AdminUserRow) -> Result<(), GroupsApiError> {
|
||||
if !user.is_active {
|
||||
return Err(GroupsApiError::TargetInactive {
|
||||
username: user.username.clone(),
|
||||
});
|
||||
}
|
||||
if user.instance_role != InstanceRole::Member {
|
||||
return Err(GroupsApiError::TargetNotMember {
|
||||
username: user.username.clone(),
|
||||
instance_role: user.instance_role,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compose_dto(user: AdminUserRow, membership: GroupMembershipRow) -> GroupMemberDto {
|
||||
GroupMemberDto {
|
||||
user_id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
instance_role: user.instance_role,
|
||||
is_active: user.is_active,
|
||||
role: membership.role,
|
||||
created_at: membership.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupsApiError {
|
||||
#[error("group not found: {0}")]
|
||||
GroupNotFound(String),
|
||||
#[error("user not found: {0}")]
|
||||
UserNotFound(AdminUserId),
|
||||
#[error("{username} is already a member of this group")]
|
||||
AlreadyMember { username: String },
|
||||
#[error("membership not found")]
|
||||
MembershipNotFound,
|
||||
#[error("{username} is deactivated")]
|
||||
TargetInactive { username: String },
|
||||
#[error("{username} has instance role {instance_role:?}; only members get explicit grants")]
|
||||
TargetNotMember {
|
||||
username: String,
|
||||
instance_role: InstanceRole,
|
||||
},
|
||||
#[error("invalid slug: {0}")]
|
||||
InvalidSlug(String),
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("group repository error: {0}")]
|
||||
Repo(#[from] GroupRepositoryError),
|
||||
#[error("member repository error: {0}")]
|
||||
MembersRepo(#[from] GroupMembersRepositoryError),
|
||||
#[error("user repository error: {0}")]
|
||||
UsersRepo(#[from] crate::admin_user_repo::AdminUserRepositoryError),
|
||||
#[error("app repository error: {0}")]
|
||||
AppsRepo(#[from] crate::repo::ScriptRepositoryError),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for GroupsApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for GroupsApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for GroupsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::GroupNotFound(_)
|
||||
| Self::UserNotFound(_)
|
||||
| Self::MembershipNotFound
|
||||
| Self::Repo(GroupRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::InvalidSlug(_) | Self::TargetInactive { .. } | Self::TargetNotMember { .. } => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::AlreadyMember { .. } | Self::Conflict(_) => {
|
||||
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Repo(GroupRepositoryError::Conflict(msg)) => {
|
||||
(StatusCode::CONFLICT, json!({ "error": msg }))
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "groups authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Repo(GroupRepositoryError::Db(e)) => {
|
||||
tracing::error!(error = %e, "groups api db error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::MembersRepo(e) => {
|
||||
tracing::error!(error = %e, "group members repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::UsersRepo(e) => {
|
||||
tracing::error!(error = %e, "groups api user repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::AppsRepo(e) => {
|
||||
tracing::error!(error = %e, "groups api app repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,11 @@ impl InvokeServiceImpl {
|
||||
if script.app_id != cx.app_id {
|
||||
return Err(InvokeError::CrossApp);
|
||||
}
|
||||
if !script.enabled {
|
||||
// §4.3: a disabled script is not invocable via any path. Surface as
|
||||
// NotFound (indistinguishable from absent), like the data plane.
|
||||
return Err(InvokeError::NotFound(format!("id {script_id}")));
|
||||
}
|
||||
Ok(ResolvedScript {
|
||||
script_id: script.id,
|
||||
app_id: script.app_id,
|
||||
@@ -103,6 +108,9 @@ impl InvokeServiceImpl {
|
||||
.await
|
||||
.map_err(|e| InvokeError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| InvokeError::NotFound(format!("name {name:?}")))?;
|
||||
if !script.enabled {
|
||||
return Err(InvokeError::NotFound(format!("name {name:?}")));
|
||||
}
|
||||
Ok(ResolvedScript {
|
||||
script_id: script.id,
|
||||
app_id: script.app_id,
|
||||
@@ -313,6 +321,7 @@ mod tests {
|
||||
timeout_seconds: 30,
|
||||
memory_limit_mb: 64,
|
||||
sandbox: ScriptSandbox::default(),
|
||||
enabled: true,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ pub mod files_repo;
|
||||
pub mod files_service;
|
||||
pub mod files_sweep;
|
||||
pub mod gc;
|
||||
pub mod group_members_repo;
|
||||
pub mod group_repo;
|
||||
pub mod groups_api;
|
||||
pub mod http_service;
|
||||
pub mod invoke_service;
|
||||
pub mod kv_api;
|
||||
@@ -165,6 +168,15 @@ pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo};
|
||||
pub use files_service::FilesServiceImpl;
|
||||
pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepStats};
|
||||
pub use gc::{spawn_abandoned_gc, spawn_app_user_token_gc, spawn_dead_letter_gc};
|
||||
pub use group_members_repo::{
|
||||
GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
|
||||
PostgresGroupMembersRepository,
|
||||
};
|
||||
pub use group_repo::{
|
||||
GroupChildCounts, GroupRepository, GroupRepositoryError, PostgresGroupRepository,
|
||||
ROOT_GROUP_SLUG,
|
||||
};
|
||||
pub use groups_api::{groups_router, GroupsApiError, GroupsState};
|
||||
pub use http_service::{HttpConfig, HttpServiceImpl};
|
||||
pub use kv_api::{kv_admin_router, KvAdminState};
|
||||
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
|
||||
|
||||
@@ -154,6 +154,8 @@ pub struct NewScript {
|
||||
/// Sandbox overrides; `None` means store an empty object (use
|
||||
/// platform defaults at exec time).
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
/// Three-state lifecycle (§4.3). Create active by default.
|
||||
pub enabled: bool,
|
||||
/// v1.1.3: literal-path `import "<name>"` declarations extracted
|
||||
/// from the source. The repo writes these into `script_imports`
|
||||
/// transactionally with the script row. Empty when validation
|
||||
@@ -176,6 +178,8 @@ pub struct ScriptPatch {
|
||||
/// rejects unsafe transitions (e.g. endpoint→module when routes
|
||||
/// or triggers reference the script).
|
||||
pub kind: Option<ScriptKind>,
|
||||
/// `Some(v)` toggles the three-state lifecycle flag; `None` leaves it.
|
||||
pub enabled: Option<bool>,
|
||||
/// v1.1.3: when `source` is also `Some`, the repo replaces the
|
||||
/// `script_imports` edges for this script with these names.
|
||||
/// `None` keeps the existing edges untouched (a name/description
|
||||
@@ -203,7 +207,8 @@ impl PostgresScriptRepository {
|
||||
/// adding `kind` (v1.1.3) and future columns can't accidentally skip
|
||||
/// one query.
|
||||
const SCRIPT_SELECT_COLS: &str = "id, app_id, name, description, version, source, kind, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at";
|
||||
timeout_seconds, memory_limit_mb, sandbox, enabled, \
|
||||
created_at, updated_at";
|
||||
|
||||
#[async_trait]
|
||||
impl ScriptRepository for PostgresScriptRepository {
|
||||
@@ -393,8 +398,8 @@ pub(crate) async fn insert_script_tx(
|
||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"INSERT INTO scripts ( \
|
||||
app_id, name, description, source, kind, \
|
||||
timeout_seconds, memory_limit_mb, sandbox \
|
||||
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8) \
|
||||
timeout_seconds, memory_limit_mb, sandbox, enabled \
|
||||
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8, $9) \
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(input.app_id.into_inner())
|
||||
@@ -405,6 +410,7 @@ pub(crate) async fn insert_script_tx(
|
||||
.bind(input.timeout_seconds)
|
||||
.bind(input.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.bind(input.enabled)
|
||||
.fetch_one(&mut **tx)
|
||||
.await;
|
||||
let script: Script = match res {
|
||||
@@ -441,6 +447,7 @@ pub(crate) async fn update_script_tx(
|
||||
memory_limit_mb = COALESCE($7, memory_limit_mb), \
|
||||
sandbox = COALESCE($8, sandbox), \
|
||||
kind = COALESCE($9, kind), \
|
||||
enabled = COALESCE($10, enabled), \
|
||||
version = version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
@@ -455,6 +462,7 @@ pub(crate) async fn update_script_tx(
|
||||
.bind(patch.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.bind(patch.kind.map(ScriptKind::as_str))
|
||||
.bind(patch.enabled)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await;
|
||||
let script: Script = match res {
|
||||
@@ -505,6 +513,7 @@ struct ScriptRow {
|
||||
timeout_seconds: i32,
|
||||
memory_limit_mb: i32,
|
||||
sandbox: serde_json::Value,
|
||||
enabled: bool,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
@@ -531,6 +540,7 @@ impl From<ScriptRow> for Script {
|
||||
timeout_seconds: u32::try_from(r.timeout_seconds).unwrap_or(30),
|
||||
memory_limit_mb: u32::try_from(r.memory_limit_mb).unwrap_or(256),
|
||||
sandbox,
|
||||
enabled: r.enabled,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
|
||||
@@ -229,6 +229,8 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
path: normalized_path,
|
||||
method: input.method,
|
||||
dispatch_mode: input.dispatch_mode,
|
||||
// Routes are created active; toggling is a dedicated path.
|
||||
enabled: true,
|
||||
})
|
||||
.await?;
|
||||
refresh_table(&state).await?;
|
||||
@@ -394,6 +396,9 @@ async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
|
||||
#[must_use]
|
||||
pub fn compile_routes(rows: &[Route]) -> Vec<CompiledRoute> {
|
||||
rows.iter()
|
||||
// A disabled route (§4.3) is dropped from the match table entirely, so
|
||||
// a request to it 404s indistinguishably from an absent route.
|
||||
.filter(|r| r.enabled)
|
||||
.filter_map(|r| match compile_route(r) {
|
||||
Ok(compiled) => Some(compiled),
|
||||
Err(e) => {
|
||||
@@ -625,6 +630,7 @@ mod tests {
|
||||
path: path.to_string(),
|
||||
method: None,
|
||||
dispatch_mode: DispatchMode::default(),
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
@@ -651,4 +657,16 @@ mod tests {
|
||||
"a reserved-path route must be skipped, never abort the compile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_route_is_dropped_from_compiled_table() {
|
||||
// §4.3: a disabled route is excluded from the match table, so a
|
||||
// request to it 404s indistinguishably from an absent route.
|
||||
let active = route_with_path("/on");
|
||||
let mut disabled = route_with_path("/off");
|
||||
disabled.enabled = false;
|
||||
let compiled = compile_routes(&[active.clone(), disabled.clone()]);
|
||||
let ids: Vec<Uuid> = compiled.iter().map(|c| c.route_id).collect();
|
||||
assert_eq!(ids, vec![active.id], "only the enabled route compiles");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ pub struct NewRoute {
|
||||
pub path: String,
|
||||
pub method: Option<String>,
|
||||
pub dispatch_mode: DispatchMode,
|
||||
/// Three-state lifecycle (§4.3). Create active by default.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -63,7 +65,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes ORDER BY created_at",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -74,7 +76,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes WHERE id = $1",
|
||||
)
|
||||
.bind(route_id)
|
||||
@@ -86,7 +88,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes WHERE app_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
@@ -101,7 +103,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(script_id.into_inner())
|
||||
@@ -173,10 +175,10 @@ pub(crate) async fn insert_route_tx(
|
||||
let res = sqlx::query_as::<_, RouteRow>(
|
||||
"INSERT INTO routes ( \
|
||||
app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
|
||||
path_kind, path, method, dispatch_mode, enabled \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
|
||||
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at",
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at",
|
||||
)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(input.script_id.into_inner())
|
||||
@@ -187,6 +189,7 @@ pub(crate) async fn insert_route_tx(
|
||||
.bind(&input.path)
|
||||
.bind(input.method.as_deref())
|
||||
.bind(input.dispatch_mode.as_str())
|
||||
.bind(input.enabled)
|
||||
.fetch_one(&mut **tx)
|
||||
.await;
|
||||
match res {
|
||||
@@ -202,6 +205,12 @@ pub(crate) async fn insert_route_tx(
|
||||
}
|
||||
|
||||
/// Delete a route by id within an existing transaction.
|
||||
///
|
||||
/// Unlike the non-tx [`RouteRepository::delete`], this is intentionally
|
||||
/// idempotent: a missing row is not an error. The only caller is the
|
||||
/// reconcile engine (`ApplyService`), where "delete a route already gone"
|
||||
/// (e.g. removed out-of-band between the diff read and the write) is a
|
||||
/// no-op to converge on, not a failure to roll back the whole apply.
|
||||
pub(crate) async fn delete_route_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
route_id: Uuid,
|
||||
@@ -225,6 +234,7 @@ struct RouteRow {
|
||||
path: String,
|
||||
method: Option<String>,
|
||||
dispatch_mode: String,
|
||||
enabled: bool,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
@@ -249,6 +259,7 @@ impl From<RouteRow> for Route {
|
||||
path: r.path,
|
||||
method: r.method,
|
||||
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
|
||||
enabled: r.enabled,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,6 +383,7 @@ mod tests {
|
||||
slug: self.slug.clone(),
|
||||
name: "test".into(),
|
||||
description: None,
|
||||
group_id: picloud_shared::GroupId::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
@@ -396,6 +397,12 @@ mod tests {
|
||||
async fn list_for_user(&self, _: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
_: picloud_shared::GroupId,
|
||||
) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
if id != self.id {
|
||||
return Ok(None);
|
||||
@@ -436,6 +443,7 @@ mod tests {
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: Option<&str>,
|
||||
_: picloud_shared::GroupId,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -444,6 +452,7 @@ mod tests {
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: Option<&str>,
|
||||
_: picloud_shared::GroupId,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct Trigger {
|
||||
pub id: TriggerId,
|
||||
pub app_id: AppId,
|
||||
pub script_id: ScriptId,
|
||||
/// §4.5 per-app trigger identifier; the manifest merge/upsert key.
|
||||
pub name: String,
|
||||
pub kind: TriggerKind,
|
||||
pub enabled: bool,
|
||||
pub dispatch_mode: TriggerDispatchMode,
|
||||
@@ -735,7 +737,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'kv', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -766,6 +768,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Kv,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -800,7 +803,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'docs', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -831,6 +834,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Docs,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -863,7 +867,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'dead_letter', TRUE, 'async', 1, 'constant', 0, $3) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -891,6 +895,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::DeadLetter,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -927,7 +932,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'cron', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -957,6 +962,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Cron,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -992,7 +998,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'files', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -1023,6 +1029,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Files,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -1056,7 +1063,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'pubsub', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -1084,6 +1091,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Pubsub,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -1116,7 +1124,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -1143,6 +1151,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Email,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -1185,7 +1194,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
|
||||
let parents: Vec<TriggerRow> = sqlx::query_as(
|
||||
"SELECT id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
"SELECT id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at \
|
||||
FROM triggers WHERE app_id = $1 ORDER BY created_at DESC",
|
||||
@@ -1213,7 +1222,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
|
||||
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError> {
|
||||
let parent: Option<TriggerRow> = sqlx::query_as(
|
||||
"SELECT id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
"SELECT id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at \
|
||||
FROM triggers WHERE id = $1",
|
||||
@@ -1463,7 +1472,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'queue', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -1494,6 +1503,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Queue,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -1522,7 +1532,8 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
t.registered_by_principal \
|
||||
FROM triggers t \
|
||||
JOIN queue_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.kind = 'queue' AND t.enabled = TRUE",
|
||||
JOIN scripts s ON s.id = t.script_id \
|
||||
WHERE t.kind = 'queue' AND t.enabled = TRUE AND s.enabled = TRUE",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
@@ -1700,6 +1711,7 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, Trigg
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name,
|
||||
kind,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -1742,6 +1754,7 @@ struct TriggerRow {
|
||||
id: Uuid,
|
||||
app_id: Uuid,
|
||||
script_id: Uuid,
|
||||
name: String,
|
||||
kind: String,
|
||||
enabled: bool,
|
||||
dispatch_mode: String,
|
||||
|
||||
@@ -38,7 +38,7 @@ use crate::trigger_repo::{
|
||||
/// in practice (the dispatcher itself ticks every 100ms; reclaim ticks
|
||||
/// every 30s; an executor needs more wall-clock than this to do useful
|
||||
/// work). Reject below this with a 422 + actionable error.
|
||||
const MIN_QUEUE_VISIBILITY_TIMEOUT_SECS: u32 = 30;
|
||||
pub(crate) const MIN_QUEUE_VISIBILITY_TIMEOUT_SECS: u32 = 30;
|
||||
|
||||
/// Default soft-warning ceiling when `PICLOUD_DISPATCHER_ASYNC_EXEC_TIMEOUT_SEC`
|
||||
/// is unset. Re-exported from the dispatcher so the single source of
|
||||
@@ -892,6 +892,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Kv,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -920,6 +921,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Docs,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -948,6 +950,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::DeadLetter,
|
||||
enabled: true,
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
@@ -977,6 +980,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: TriggerKind::Email,
|
||||
enabled: true,
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
@@ -1021,6 +1025,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Cron,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -1050,6 +1055,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Files,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -1078,6 +1084,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Pubsub,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -1154,6 +1161,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: TriggerKind::Queue,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -1201,6 +1209,7 @@ mod tests {
|
||||
slug: "test".into(),
|
||||
name: "test".into(),
|
||||
description: None,
|
||||
group_id: picloud_shared::GroupId::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
@@ -1218,6 +1227,7 @@ mod tests {
|
||||
_slug: &str,
|
||||
_name: &str,
|
||||
_description: Option<&str>,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1226,6 +1236,7 @@ mod tests {
|
||||
_slug: &str,
|
||||
_name: &str,
|
||||
_description: Option<&str>,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1244,6 +1255,12 @@ mod tests {
|
||||
) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_by_id(
|
||||
&self,
|
||||
id: AppId,
|
||||
@@ -1347,6 +1364,7 @@ mod tests {
|
||||
timeout_seconds: 30,
|
||||
sandbox: picloud_shared::ScriptSandbox::default(),
|
||||
memory_limit_mb: 256,
|
||||
enabled: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
@@ -1716,6 +1734,7 @@ mod tests {
|
||||
slug: "a".into(),
|
||||
name: "a".into(),
|
||||
description: None,
|
||||
group_id: picloud_shared::GroupId::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
@@ -1727,6 +1746,7 @@ mod tests {
|
||||
slug: "b".into(),
|
||||
name: "b".into(),
|
||||
description: None,
|
||||
group_id: picloud_shared::GroupId::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
@@ -121,6 +121,11 @@ where
|
||||
.resolve(id)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound(id))?;
|
||||
// A disabled script (§4.3) is not invocable — 404, indistinguishable
|
||||
// from an absent one (no info leak that the id exists but is off).
|
||||
if !script.enabled {
|
||||
return Err(ApiError::NotFound(id));
|
||||
}
|
||||
|
||||
let mut req = build_exec_request(id, &script.name, &headers, &body, script.app_id, principal)?;
|
||||
req.sandbox_overrides = script.sandbox;
|
||||
@@ -164,6 +169,7 @@ where
|
||||
Ok(exec_response_to_http(outcome?))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn user_route_handler<E, R>(
|
||||
State(state): State<DataPlaneState<E, R>>,
|
||||
Extension(principal): Extension<Option<Principal>>,
|
||||
@@ -221,6 +227,19 @@ where
|
||||
.resolve(matched.matched.script_id)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound(matched.matched.script_id))?;
|
||||
// An enabled route bound to a disabled script (§4.3, §4.7) is unreachable.
|
||||
// Return the SAME flat "no route matches" 404 as the unmatched case — not
|
||||
// `NotFound(script_id)`, which would both leak the internal script id to an
|
||||
// anonymous caller and be distinguishable from absent.
|
||||
if !script.enabled {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("no route matches {method} {path}")
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
// Drain the body now that we know we'll execute. 10 MiB cap matches
|
||||
// the conservative default response/request size in the blueprint.
|
||||
|
||||
@@ -12,7 +12,8 @@ use chrono::{DateTime, Utc};
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
|
||||
use picloud_shared::{
|
||||
AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog,
|
||||
HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind, ScriptSandbox,
|
||||
Group, HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind,
|
||||
ScriptSandbox,
|
||||
};
|
||||
use reqwest::{header, Method, RequestBuilder, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -149,6 +150,144 @@ impl Client {
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
// --- Groups (Phase 2) -------------------------------------------------
|
||||
|
||||
/// `GET /api/v1/admin/groups` — the full flat list (assemble the tree
|
||||
/// client-side from `parent_id`).
|
||||
pub async fn groups_list(&self) -> Result<Vec<Group>> {
|
||||
let resp = self
|
||||
.request(Method::GET, "/api/v1/admin/groups")
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/groups/{id_or_slug}` — group + path + children.
|
||||
pub async fn groups_get(&self, ident: &str) -> Result<GroupDetailDto> {
|
||||
let ident = seg(ident);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/groups/{ident}"))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/groups`
|
||||
pub async fn groups_create(&self, body: &CreateGroupBody<'_>) -> Result<Group> {
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/groups")
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `PATCH /api/v1/admin/groups/{id_or_slug}` — name/description only
|
||||
/// (the slug is frozen).
|
||||
pub async fn groups_rename(
|
||||
&self,
|
||||
ident: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
) -> Result<Group> {
|
||||
let ident = seg(ident);
|
||||
let body = serde_json::json!({ "name": name, "description": description });
|
||||
let resp = self
|
||||
.request(Method::PATCH, &format!("/api/v1/admin/groups/{ident}"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/groups/{id_or_slug}/reparent` — `parent` is a
|
||||
/// slug/id, or `None` to move to root.
|
||||
pub async fn groups_reparent(&self, ident: &str, parent: Option<&str>) -> Result<Group> {
|
||||
let ident = seg(ident);
|
||||
let body = serde_json::json!({ "parent": parent });
|
||||
let resp = self
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!("/api/v1/admin/groups/{ident}/reparent"),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/groups/{id_or_slug}` — 409 if non-empty.
|
||||
pub async fn groups_delete(&self, ident: &str) -> Result<()> {
|
||||
let ident = seg(ident);
|
||||
let resp = self
|
||||
.request(Method::DELETE, &format!("/api/v1/admin/groups/{ident}"))
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
pub async fn group_members_list(&self, group: &str) -> Result<Vec<AppMemberDto>> {
|
||||
let group = seg(group);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/groups/{group}/members"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
pub async fn group_members_grant(
|
||||
&self,
|
||||
group: &str,
|
||||
user_id: &str,
|
||||
role: AppRole,
|
||||
) -> Result<AppMemberDto> {
|
||||
let group = seg(group);
|
||||
let body = serde_json::json!({ "user_id": user_id, "role": role });
|
||||
let resp = self
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!("/api/v1/admin/groups/{group}/members"),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
pub async fn group_members_set_role(
|
||||
&self,
|
||||
group: &str,
|
||||
user_id: &str,
|
||||
role: AppRole,
|
||||
) -> Result<AppMemberDto> {
|
||||
let (group, user_id) = (seg(group), seg(user_id));
|
||||
let body = serde_json::json!({ "role": role });
|
||||
let resp = self
|
||||
.request(
|
||||
Method::PATCH,
|
||||
&format!("/api/v1/admin/groups/{group}/members/{user_id}"),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
pub async fn group_members_remove(&self, group: &str, user_id: &str) -> Result<()> {
|
||||
let (group, user_id) = (seg(group), seg(user_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/admin/groups/{group}/members/{user_id}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the
|
||||
/// owning app (stricter than the edit endpoints, by design).
|
||||
pub async fn scripts_delete(&self, id: &str) -> Result<()> {
|
||||
@@ -920,14 +1059,32 @@ impl Client {
|
||||
app: &str,
|
||||
bundle: &serde_json::Value,
|
||||
prune: bool,
|
||||
expected_token: Option<&str>,
|
||||
) -> Result<ApplyReportDto> {
|
||||
let app = seg(app);
|
||||
let body = serde_json::json!({ "bundle": bundle, "prune": prune });
|
||||
let body = serde_json::json!({
|
||||
"bundle": bundle,
|
||||
"prune": prune,
|
||||
"expected_token": expected_token,
|
||||
});
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/apply"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
// The apply endpoint returns 409 only for a stale bound plan
|
||||
// (`StateMoved`): the app changed since `pic plan` recorded its
|
||||
// token. Surface an actionable next step instead of a bare
|
||||
// `HTTP 409`.
|
||||
if resp.status() == reqwest::StatusCode::CONFLICT {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let msg = parse_error_body(&body).unwrap_or(body);
|
||||
return Err(anyhow!(
|
||||
"{msg}\nThe app changed since your last `pic plan`. Re-run \
|
||||
`pic plan` to review the new diff, then `pic apply` — or \
|
||||
`pic apply --force` to apply without re-reviewing."
|
||||
));
|
||||
}
|
||||
decode(resp).await
|
||||
}
|
||||
}
|
||||
@@ -965,6 +1122,10 @@ pub struct PlanDto {
|
||||
pub triggers: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
/// Fingerprint of the live state this plan was computed against; carried
|
||||
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
||||
#[serde(default)]
|
||||
pub state_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -1027,6 +1188,31 @@ pub struct CreateAppBody<'a> {
|
||||
pub name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
/// Parent group (slug or id); omit for the instance root.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateGroupBody<'a> {
|
||||
pub slug: &'a str,
|
||||
pub name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
/// Parent group (slug or id); omit for a root-level group.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// `GET /groups/{id}` response — the group plus its breadcrumb path and
|
||||
/// direct children.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GroupDetailDto {
|
||||
#[serde(flatten)]
|
||||
pub group: Group,
|
||||
pub path: Vec<Group>,
|
||||
pub subgroups: Vec<Group>,
|
||||
pub apps: Vec<App>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! `pic apply [--file picloud.toml]` — reconcile the live app to the
|
||||
//! manifest's desired state in one server-side transaction. Additive in
|
||||
//! this milestone (creates + updates); pruning of stale resources lands
|
||||
//! next.
|
||||
//! manifest's desired state in one server-side transaction. Creates and
|
||||
//! updates are applied; `--prune` additionally deletes live scripts/routes/
|
||||
//! triggers absent from the manifest (secrets are never pruned).
|
||||
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::cmds::plan::build_bundle;
|
||||
@@ -13,15 +14,46 @@ use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
pub async fn run(manifest_path: &Path, prune: bool, mode: OutputMode) -> Result<()> {
|
||||
pub async fn run(
|
||||
manifest_path: &Path,
|
||||
env: Option<&str>,
|
||||
prune: bool,
|
||||
yes: bool,
|
||||
force: bool,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let manifest = Manifest::load(manifest_path)?;
|
||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
|
||||
let report = client.apply(&manifest.app.slug, &bundle, prune).await?;
|
||||
if prune && !yes {
|
||||
confirm_prune(&manifest.app.slug)?;
|
||||
}
|
||||
|
||||
// Bound-plan check: replay the token from the last `pic plan` (for this
|
||||
// app) so the server refuses if the app changed since it was reviewed.
|
||||
// `--force` skips it; no recorded plan means no check (apply still works
|
||||
// standalone). The token is single-use — cleared after a successful apply.
|
||||
let expected_token = if force {
|
||||
None
|
||||
} else {
|
||||
crate::linkstate::read_plan(base_dir)
|
||||
.filter(|l| l.app == manifest.app.slug)
|
||||
.map(|l| l.state_token)
|
||||
};
|
||||
|
||||
let report = client
|
||||
.apply(
|
||||
&manifest.app.slug,
|
||||
&bundle,
|
||||
prune,
|
||||
expected_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
crate::linkstate::clear_plan(base_dir, &manifest.app.slug);
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
@@ -50,3 +82,30 @@ pub async fn run(manifest_path: &Path, prune: bool, mode: OutputMode) -> Result<
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `--prune` deletes live scripts/routes/triggers absent from the manifest —
|
||||
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
|
||||
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
|
||||
/// rather than silently deleting (review the deletions first with `pic plan`).
|
||||
fn confirm_prune(slug: &str) -> Result<()> {
|
||||
if !std::io::stdin().is_terminal() {
|
||||
anyhow::bail!(
|
||||
"refusing to `apply --prune` non-interactively without `--yes`: prune \
|
||||
deletes resources absent from the manifest and cannot be undone. \
|
||||
Review with `pic plan`, then re-run with `--yes`."
|
||||
);
|
||||
}
|
||||
eprint!(
|
||||
"apply --prune will DELETE live scripts/routes/triggers on `{slug}` that are \
|
||||
absent from the manifest. This cannot be undone. Continue? [y/N] "
|
||||
);
|
||||
std::io::stderr().flush().ok();
|
||||
let mut answer = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut answer)
|
||||
.context("read confirmation")?;
|
||||
if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") {
|
||||
anyhow::bail!("aborted");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ pub async fn create(
|
||||
slug: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
group: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
@@ -39,6 +40,7 @@ pub async fn create(
|
||||
slug,
|
||||
name: name.unwrap_or(slug),
|
||||
description,
|
||||
group,
|
||||
};
|
||||
let app = client.apps_create(&body).await?;
|
||||
// Emit the created object so `--output json` callers can capture the
|
||||
|
||||
58
crates/picloud-cli/src/cmds/config.rs
Normal file
58
crates/picloud-cli/src/cmds/config.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! `pic config --effective` — read-only view of an app's resolved
|
||||
//! configuration, with secret values masked (§4.6).
|
||||
//!
|
||||
//! Single-app today: "config" means the app's secrets, cross-referenced
|
||||
//! against the manifest so an operator can see at a glance which declared
|
||||
//! secrets are still unset and which live secrets aren't declared. Values are
|
||||
//! never fetched or shown — only `<set>` / `<unset>`. The command is shaped to
|
||||
//! grow an `--explain` mode and inherited `vars` once groups/vars land (the
|
||||
//! Phase-3 multi-level resolution).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn run(
|
||||
manifest_path: &Path,
|
||||
effective: bool,
|
||||
env: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
if !effective {
|
||||
bail!("`pic config` currently supports only `--effective`");
|
||||
}
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
// Resolve against the same env overlay as `pic plan`/`apply` so the
|
||||
// masked report targets the app those commands would act on — not the
|
||||
// base slug — when an overlay re-points slug/secrets.
|
||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||
|
||||
let declared: BTreeSet<String> = manifest.secrets.names.iter().cloned().collect();
|
||||
let on_server: BTreeSet<String> = client
|
||||
.secrets_list(&manifest.app.slug)
|
||||
.await?
|
||||
.secrets
|
||||
.into_iter()
|
||||
.map(|s| s.name)
|
||||
.collect();
|
||||
|
||||
let mut table = Table::new(["secret", "value", "status"]);
|
||||
for name in declared.union(&on_server) {
|
||||
let (value, status) = match (declared.contains(name), on_server.contains(name)) {
|
||||
(true, true) => ("<set>", "managed"),
|
||||
(true, false) => ("<unset>", "declared, not pushed — `pic secret set`"),
|
||||
(false, true) => ("<set>", "on server, not in manifest"),
|
||||
(false, false) => unreachable!("name came from one of the two sets"),
|
||||
};
|
||||
table.row([name.clone(), value.to_string(), status.to_string()]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
257
crates/picloud-cli/src/cmds/groups.rs
Normal file
257
crates/picloud-cli/src/cmds/groups.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
//! `pic groups` — manage the org-tree groups (Phase 2).
|
||||
//!
|
||||
//! Wraps `/api/v1/admin/groups*`. Structural mutations are gated
|
||||
//! server-side: create/reparent/delete need group-admin (reparent at both
|
||||
//! source and destination parent); the slug is frozen at creation.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use picloud_shared::{AppRole, Group};
|
||||
|
||||
use crate::client::{Client, CreateGroupBody};
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn ls(mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let groups = client.groups_list().await?;
|
||||
let mut table = Table::new(["slug", "name", "parent", "created_at"]);
|
||||
let by_id: BTreeMap<_, _> = groups.iter().map(|g| (g.id, g.slug.clone())).collect();
|
||||
for g in &groups {
|
||||
let parent = g
|
||||
.parent_id
|
||||
.and_then(|p| by_id.get(&p).cloned())
|
||||
.unwrap_or_else(|| "-".into());
|
||||
table.row([
|
||||
g.slug.clone(),
|
||||
g.name.clone(),
|
||||
parent,
|
||||
g.created_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic groups tree` — render the hierarchy as an indented tree (text
|
||||
/// mode); falls back to the flat list for `--output json`.
|
||||
pub async fn tree(mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let groups = client.groups_list().await?;
|
||||
if matches!(mode, OutputMode::Json) {
|
||||
// Machine consumers get the flat list; the shape carries parent_id.
|
||||
println!("{}", serde_json::to_string_pretty(&groups)?);
|
||||
return Ok(());
|
||||
}
|
||||
// children-by-parent, then DFS from the roots.
|
||||
let mut children: BTreeMap<Option<_>, Vec<&Group>> = BTreeMap::new();
|
||||
for g in &groups {
|
||||
children.entry(g.parent_id).or_default().push(g);
|
||||
}
|
||||
for kids in children.values_mut() {
|
||||
kids.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
}
|
||||
print_subtree(&children, None, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_subtree(
|
||||
children: &BTreeMap<Option<picloud_shared::GroupId>, Vec<&Group>>,
|
||||
parent: Option<picloud_shared::GroupId>,
|
||||
depth: usize,
|
||||
) {
|
||||
let Some(kids) = children.get(&parent) else {
|
||||
return;
|
||||
};
|
||||
for g in kids {
|
||||
println!("{}{} ({})", " ".repeat(depth), g.name, g.slug);
|
||||
print_subtree(children, Some(g.id), depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
slug: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
parent: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let body = CreateGroupBody {
|
||||
slug,
|
||||
name: name.unwrap_or(slug),
|
||||
description,
|
||||
parent,
|
||||
};
|
||||
let group = client.groups_create(&body).await?;
|
||||
print_group(&group, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn show(ident: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let detail = client.groups_get(ident).await?;
|
||||
let path = detail
|
||||
.path
|
||||
.iter()
|
||||
.map(|g| g.slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("id", detail.group.id.to_string())
|
||||
.field("slug", detail.group.slug.clone())
|
||||
.field("name", detail.group.name.clone())
|
||||
.field("path", if path.is_empty() { "-".into() } else { path })
|
||||
.field(
|
||||
"subgroups",
|
||||
detail
|
||||
.subgroups
|
||||
.iter()
|
||||
.map(|g| g.slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
.field(
|
||||
"apps",
|
||||
detail
|
||||
.apps
|
||||
.iter()
|
||||
.map(|a| a.slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
);
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rename(
|
||||
ident: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let group = client.groups_rename(ident, name, description).await?;
|
||||
print_group(&group, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reparent(ident: &str, to: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let group = client.groups_reparent(ident, to).await?;
|
||||
print_group(&group, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic groups rm <slug>`. The server enforces delete=RESTRICT (409 on a
|
||||
/// non-empty group); `--recursive` expands the delete into ordered,
|
||||
/// leaf-first child deletions (groups + apps) the operator opted into.
|
||||
pub async fn rm(ident: &str, recursive: bool) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
if !recursive {
|
||||
client.groups_delete(ident).await?;
|
||||
println!("Deleted group {ident}");
|
||||
return Ok(());
|
||||
}
|
||||
// Recursive: delete the subtree leaf-first so each DB delete sees an
|
||||
// empty node (the FK stays RESTRICT — we never cascade implicitly).
|
||||
let detail = client.groups_get(ident).await?;
|
||||
if let Some(app) = detail.apps.first() {
|
||||
anyhow::bail!(
|
||||
"group {ident} contains app {:?}; move or delete apps before a recursive group delete \
|
||||
(apps are never auto-deleted)",
|
||||
app.slug
|
||||
);
|
||||
}
|
||||
for sub in &detail.subgroups {
|
||||
Box::pin(rm(&sub.slug, true)).await?;
|
||||
}
|
||||
client.groups_delete(ident).await?;
|
||||
println!("Deleted group {ident}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- members ---------------------------------------------------------------
|
||||
|
||||
pub async fn members_ls(group: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let members = client.group_members_list(group).await?;
|
||||
let mut table = Table::new(["user_id", "username", "role", "instance_role", "active"]);
|
||||
for m in members {
|
||||
table.row([
|
||||
m.user_id.to_string(),
|
||||
m.username,
|
||||
m.role.as_str().to_string(),
|
||||
format!("{:?}", m.instance_role).to_lowercase(),
|
||||
m.is_active.to_string(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn members_add(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let m = client
|
||||
.group_members_grant(group, user_id, parse_role(role)?)
|
||||
.await?;
|
||||
print_member(&m, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn members_set(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let m = client
|
||||
.group_members_set_role(group, user_id, parse_role(role)?)
|
||||
.await?;
|
||||
print_member(&m, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn members_rm(group: &str, user_id: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.group_members_remove(group, user_id).await?;
|
||||
println!("Removed {user_id} from {group}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_group(g: &Group, mode: OutputMode) {
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("id", g.id.to_string())
|
||||
.field("slug", g.slug.clone())
|
||||
.field("name", g.name.clone())
|
||||
.field(
|
||||
"parent_id",
|
||||
g.parent_id.map_or_else(|| "-".into(), |p| p.to_string()),
|
||||
)
|
||||
.field("created_at", g.created_at.to_rfc3339());
|
||||
block.print(mode);
|
||||
}
|
||||
|
||||
fn print_member(m: &crate::client::AppMemberDto, mode: OutputMode) {
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("user_id", m.user_id.to_string())
|
||||
.field("username", m.username.clone())
|
||||
.field("role", m.role.as_str().to_string());
|
||||
block.print(mode);
|
||||
}
|
||||
|
||||
fn parse_role(role: &str) -> Result<AppRole> {
|
||||
AppRole::from_db_str(role)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid role {role:?}; want app_admin | editor | viewer"))
|
||||
}
|
||||
278
crates/picloud-cli/src/cmds/init.rs
Normal file
278
crates/picloud-cli/src/cmds/init.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
//! `pic init [slug] [--dir .]` — scaffold a new declarative project: a
|
||||
//! `picloud.toml` describing a minimal working app (one `hello` endpoint +
|
||||
//! route), its `scripts/hello.rhai` source, and a `.gitignore` that ignores
|
||||
//! the project tool's `.picloud/` link-state directory.
|
||||
//!
|
||||
//! Offline by design — it never contacts the server. Run `pic plan` to
|
||||
//! preview the create, then `pic apply` to deploy. Refuses to overwrite an
|
||||
//! existing `picloud.toml` unless `--force`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use picloud_shared::{DispatchMode, HostKind, PathKind, ScriptKind};
|
||||
|
||||
use crate::manifest::{Manifest, ManifestApp, ManifestRoute, ManifestScript, MANIFEST_FILE};
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
const HEADER: &str = "\
|
||||
# picloud project manifest — the declarative desired state for one app.
|
||||
# Edit, then `pic plan` to preview changes and `pic apply` to reconcile.
|
||||
# Scripts live under scripts/ and are referenced by `file`.
|
||||
\n";
|
||||
|
||||
const EXAMPLES: &str = "\
|
||||
\n# ---------------------------------------------------------------------------
|
||||
# More to add (uncomment and adapt):
|
||||
#
|
||||
# [[scripts]]
|
||||
# name = \"lib\"
|
||||
# file = \"scripts/lib.rhai\"
|
||||
# kind = \"module\" # default: endpoint
|
||||
#
|
||||
# [[routes]]
|
||||
# script = \"hello\"
|
||||
# method = \"POST\" # omit for ANY
|
||||
# host_kind = \"any\"
|
||||
# path_kind = \"param\" # exact | prefix | param
|
||||
# path = \"/hello/:name\"
|
||||
#
|
||||
# [[triggers.cron]]
|
||||
# script = \"hello\"
|
||||
# schedule = \"0 0 * * * *\" # 6-field cron (seconds first)
|
||||
# timezone = \"UTC\"
|
||||
#
|
||||
# [secrets] # names only — push values with `pic secret set`
|
||||
# names = [\"STRIPE_KEY\"]
|
||||
";
|
||||
|
||||
const HELLO_RHAI: &str = "\
|
||||
// A minimal endpoint script. Its return value is the HTTP response body.
|
||||
// `ctx` exposes the request; see the stdlib reference for the full SDK.
|
||||
\"Hello from PiCloud!\"
|
||||
";
|
||||
|
||||
const GITIGNORE_LINE: &str = ".picloud/";
|
||||
|
||||
pub fn run(
|
||||
dir: &Path,
|
||||
slug_arg: Option<&str>,
|
||||
name_arg: Option<&str>,
|
||||
force: bool,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let slug = resolve_slug(dir, slug_arg)?;
|
||||
let name = name_arg.map_or_else(|| title_from_slug(&slug), str::to_string);
|
||||
|
||||
let manifest_path = dir.join(MANIFEST_FILE);
|
||||
if manifest_path.exists() && !force {
|
||||
bail!(
|
||||
"{} already exists; refusing to overwrite (use --force)",
|
||||
manifest_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let manifest = scaffold_manifest(&slug, &name);
|
||||
// Build the file as a commented header + the (valid, round-trippable)
|
||||
// active manifest + commented examples. Rendering the active part through
|
||||
// `to_toml` guarantees it parses and matches the wire model.
|
||||
let body = format!("{HEADER}{}{EXAMPLES}", manifest.to_toml()?);
|
||||
|
||||
fs::create_dir_all(dir.join("scripts")).context("creating scripts/ directory")?;
|
||||
let hello_path = dir.join("scripts/hello.rhai");
|
||||
let wrote_hello = !hello_path.exists();
|
||||
if wrote_hello {
|
||||
fs::write(&hello_path, HELLO_RHAI).context("writing scripts/hello.rhai")?;
|
||||
}
|
||||
fs::write(&manifest_path, body).with_context(|| format!("writing {MANIFEST_FILE}"))?;
|
||||
ensure_gitignored(dir)?;
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("manifest", manifest_path.display().to_string())
|
||||
.field("app", slug)
|
||||
.field(
|
||||
"scripts",
|
||||
if wrote_hello {
|
||||
"scripts/hello.rhai"
|
||||
} else {
|
||||
"scripts/hello.rhai (kept existing)"
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
.field("next", "pic plan then pic apply".to_string());
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The minimal working project: one `hello` endpoint bound to `GET /hello`.
|
||||
fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
Manifest {
|
||||
app: ManifestApp {
|
||||
slug: slug.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
},
|
||||
scripts: vec![ManifestScript {
|
||||
name: "hello".into(),
|
||||
file: "scripts/hello.rhai".into(),
|
||||
kind: ScriptKind::Endpoint,
|
||||
description: None,
|
||||
timeout_seconds: None,
|
||||
memory_limit_mb: None,
|
||||
sandbox: None,
|
||||
enabled: true,
|
||||
}],
|
||||
routes: vec![ManifestRoute {
|
||||
script: "hello".into(),
|
||||
method: Some("GET".into()),
|
||||
host_kind: HostKind::Any,
|
||||
host: String::new(),
|
||||
host_param_name: None,
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/hello".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
}],
|
||||
triggers: crate::manifest::ManifestTriggers::default(),
|
||||
secrets: crate::manifest::ManifestSecrets::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the explicit slug if given, else derive one from the target
|
||||
/// directory's name. Either way it must satisfy the app-slug rule.
|
||||
fn resolve_slug(dir: &Path, slug_arg: Option<&str>) -> Result<String> {
|
||||
if let Some(s) = slug_arg {
|
||||
if !is_valid_slug(s) {
|
||||
bail!("invalid app slug `{s}`: use lowercase letters, digits, and dashes (start alphanumeric, max 63)");
|
||||
}
|
||||
return Ok(s.to_string());
|
||||
}
|
||||
// Derive from the directory's own name. Use the path as given first —
|
||||
// `canonicalize` requires the dir to already exist, which breaks the
|
||||
// natural `pic init --dir new-project` flow. Fall back to canonicalizing
|
||||
// only when the path has no final component of its own (e.g. `.`).
|
||||
let base = dir
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.or_else(|| {
|
||||
dir.canonicalize()
|
||||
.ok()
|
||||
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let derived = slugify(&base);
|
||||
if !is_valid_slug(&derived) {
|
||||
bail!(
|
||||
"could not derive a valid app slug from directory `{base}`; \
|
||||
pass one explicitly, e.g. `pic init my-app`"
|
||||
);
|
||||
}
|
||||
Ok(derived)
|
||||
}
|
||||
|
||||
/// Lowercase, map runs of non-`[a-z0-9]` to a single `-`, trim dashes.
|
||||
fn slugify(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut prev_dash = false;
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c.to_ascii_lowercase());
|
||||
prev_dash = false;
|
||||
} else if !prev_dash {
|
||||
out.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
}
|
||||
out.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
/// The canonical app-slug rule (mirrors the server): `^[a-z0-9][a-z0-9-]{0,62}$`.
|
||||
fn is_valid_slug(s: &str) -> bool {
|
||||
if s.is_empty() || s.len() > 63 {
|
||||
return false;
|
||||
}
|
||||
let mut chars = s.chars();
|
||||
let first = chars.next().expect("non-empty checked above");
|
||||
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
}
|
||||
|
||||
/// Title-case a slug for a default display name: `my-blog` → `My Blog`.
|
||||
fn title_from_slug(slug: &str) -> String {
|
||||
slug.split('-')
|
||||
.filter(|w| !w.is_empty())
|
||||
.map(|w| {
|
||||
let mut c = w.chars();
|
||||
c.next().map_or_else(String::new, |f| {
|
||||
f.to_ascii_uppercase().to_string() + c.as_str()
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Ensure `.gitignore` ignores `.picloud/` (the project tool's link state).
|
||||
/// Appends the line if missing; creates the file if absent. A repo without
|
||||
/// git still gets a correct `.gitignore` for when it's initialized.
|
||||
fn ensure_gitignored(dir: &Path) -> Result<()> {
|
||||
let path = dir.join(".gitignore");
|
||||
// Only a genuinely-absent file is treated as empty; an existing-but-
|
||||
// unreadable `.gitignore` must error rather than be silently clobbered.
|
||||
let existing = match fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => return Err(e).context("reading .gitignore"),
|
||||
};
|
||||
if existing.lines().any(|l| l.trim() == GITIGNORE_LINE) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut next = existing;
|
||||
if !next.is_empty() && !next.ends_with('\n') {
|
||||
next.push('\n');
|
||||
}
|
||||
next.push_str(GITIGNORE_LINE);
|
||||
next.push('\n');
|
||||
fs::write(&path, next).context("updating .gitignore")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scaffold_is_valid_and_round_trips() {
|
||||
let m = scaffold_manifest("blog", "Blog");
|
||||
let body = format!("{HEADER}{}{EXAMPLES}", m.to_toml().unwrap());
|
||||
// The active manifest (header/examples are comments) must parse back
|
||||
// to exactly the scaffold — the commented examples are inert.
|
||||
let parsed = Manifest::parse(&body).expect("scaffold must be valid TOML");
|
||||
assert_eq!(parsed, m);
|
||||
// And it's a deployable project: one endpoint + its route.
|
||||
assert_eq!(parsed.scripts.len(), 1);
|
||||
assert_eq!(parsed.routes.len(), 1);
|
||||
assert_eq!(parsed.routes[0].script, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_and_validation() {
|
||||
assert_eq!(slugify("My Blog!"), "my-blog");
|
||||
assert_eq!(slugify(" weird__name "), "weird-name");
|
||||
assert_eq!(slugify("Project (2026)"), "project-2026");
|
||||
assert!(is_valid_slug("blog"));
|
||||
assert!(is_valid_slug("a1-b2"));
|
||||
assert!(!is_valid_slug(""));
|
||||
assert!(!is_valid_slug("-leading"));
|
||||
assert!(!is_valid_slug(&"a".repeat(64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_from_slug_humanizes() {
|
||||
assert_eq!(title_from_slug("my-blog"), "My Blog");
|
||||
assert_eq!(title_from_slug("api"), "Api");
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@ pub mod api_keys;
|
||||
pub mod apply;
|
||||
pub mod apps;
|
||||
pub mod apps_domains;
|
||||
pub mod config;
|
||||
pub mod dead_letters;
|
||||
pub mod files;
|
||||
pub mod groups;
|
||||
pub mod init;
|
||||
pub mod kv;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
|
||||
@@ -14,15 +14,25 @@ use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn run(manifest_path: &Path, mode: OutputMode) -> Result<()> {
|
||||
pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let manifest = Manifest::load(manifest_path)?;
|
||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
|
||||
let plan = client.plan(&manifest.app.slug, &bundle).await?;
|
||||
// Record the bound-plan token so a subsequent `pic apply` can detect the
|
||||
// app changing underneath the reviewed plan (best-effort — a read-only
|
||||
// plan still succeeds if the project dir isn't writable).
|
||||
if !plan.state_token.is_empty() {
|
||||
if let Err(e) =
|
||||
crate::linkstate::write_plan(base_dir, &manifest.app.slug, &plan.state_token)
|
||||
{
|
||||
eprintln!("warning: could not record plan state for `pic apply`: {e}");
|
||||
}
|
||||
}
|
||||
render(&plan, mode);
|
||||
Ok(())
|
||||
}
|
||||
@@ -51,6 +61,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
if let Some(sb) = &s.sandbox {
|
||||
obj.insert("sandbox".into(), serde_json::to_value(sb)?);
|
||||
}
|
||||
obj.insert("enabled".into(), json!(s.enabled));
|
||||
scripts.push(Value::Object(obj));
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,24 @@ use crate::manifest::{
|
||||
};
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
|
||||
pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
// Refuse to clobber an existing project (mirrors `pic init`). `pull`
|
||||
// overwrites `picloud.toml` and every colliding `scripts/*.rhai`, so a
|
||||
// stray `pic pull <wrong-app>` in a populated dir would destroy local
|
||||
// edits. Fail fast — before any network call or file write — unless the
|
||||
// operator opted in with `--force`.
|
||||
let manifest_path = dir.join(MANIFEST_FILE);
|
||||
if !force && manifest_path.exists() {
|
||||
anyhow::bail!(
|
||||
"{} already exists; refusing to overwrite. Re-run with --force to \
|
||||
replace it (and any colliding scripts/*.rhai).",
|
||||
manifest_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// One GET per resource kind (routes are per-script, below).
|
||||
let app = client.apps_get(app_ident).await?;
|
||||
let scripts = client.scripts_list_by_app(app_ident).await?;
|
||||
@@ -49,6 +63,7 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
|
||||
path_kind: r.path_kind,
|
||||
path: r.path,
|
||||
dispatch_mode: r.dispatch_mode,
|
||||
enabled: r.enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -62,8 +77,9 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
|
||||
for s in &scripts {
|
||||
if !is_safe_filename(&s.name) {
|
||||
anyhow::bail!(
|
||||
"script name {:?} is not filesystem-safe (contains a path \
|
||||
separator, `..`, or a leading dot); cannot pull",
|
||||
"script name {:?} is not filesystem-safe (a path separator, \
|
||||
`..`, a leading dot, a control character, or longer than 200 \
|
||||
bytes); cannot pull",
|
||||
s.name
|
||||
);
|
||||
}
|
||||
@@ -89,6 +105,7 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
|
||||
} else {
|
||||
Some(s.sandbox)
|
||||
},
|
||||
enabled: s.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,7 +203,6 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
|
||||
},
|
||||
};
|
||||
|
||||
let manifest_path = dir.join(MANIFEST_FILE);
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
.with_context(|| format!("writing {}", manifest_path.display()))?;
|
||||
|
||||
@@ -207,14 +223,35 @@ fn trigger_count(t: &ManifestTriggers) -> usize {
|
||||
}
|
||||
|
||||
/// True if `name` is safe to use as a single path component in `scripts/`.
|
||||
/// Rejects empty names, path separators, `.`/`..`, and leading dots.
|
||||
/// Rejects empty/over-long names, path separators, `.`/`..`, leading dots,
|
||||
/// and any deceptive display character — a server-returned name is otherwise
|
||||
/// written verbatim as a filename and printed to the operator's terminal.
|
||||
fn is_safe_filename(name: &str) -> bool {
|
||||
// Leave headroom under NAME_MAX (255 bytes on common filesystems) for the
|
||||
// `.rhai` suffix.
|
||||
const MAX_LEN: usize = 200;
|
||||
!name.is_empty()
|
||||
&& name.len() <= MAX_LEN
|
||||
&& !name.starts_with('.')
|
||||
&& !name.contains('/')
|
||||
&& !name.contains('\\')
|
||||
&& name != ".."
|
||||
&& !name.contains('\0')
|
||||
&& !name.chars().any(is_deceptive_char)
|
||||
}
|
||||
|
||||
/// Control characters (NUL, newlines, ANSI escapes) plus the Unicode
|
||||
/// bidirectional-override and zero-width/format characters used to spoof how a
|
||||
/// name renders in a terminal — both classes are unsafe to print verbatim.
|
||||
fn is_deceptive_char(c: char) -> bool {
|
||||
c.is_control()
|
||||
|| matches!(c,
|
||||
'\u{200B}'..='\u{200F}' // zero-width space … LTR/RTL marks
|
||||
| '\u{2028}'..='\u{2029}' // line / paragraph separators
|
||||
| '\u{202A}'..='\u{202E}' // bidi embeddings / overrides
|
||||
| '\u{2060}' // word joiner
|
||||
| '\u{2066}'..='\u{2069}' // bidi isolates
|
||||
| '\u{FEFF}' // BOM / zero-width no-break space
|
||||
)
|
||||
}
|
||||
|
||||
/// Deserialize a trigger's `details` JSON, attributing failures to the kind.
|
||||
@@ -276,6 +313,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_control_chars_and_overlong() {
|
||||
for bad in [
|
||||
"a\nb",
|
||||
"a\tb",
|
||||
"line\rdrop",
|
||||
"esc\x1b[2Jseq",
|
||||
"rtl\u{202E}gpj.exe", // bidi override (filename spoof)
|
||||
"zero\u{200B}width", // zero-width space
|
||||
"bom\u{FEFF}name", // BOM
|
||||
] {
|
||||
assert!(!is_safe_filename(bad), "expected {bad:?} to be rejected");
|
||||
}
|
||||
assert!(
|
||||
!is_safe_filename(&"a".repeat(201)),
|
||||
"expected an over-long name to be rejected"
|
||||
);
|
||||
assert!(
|
||||
is_safe_filename(&"a".repeat(200)),
|
||||
"a 200-char name is at the limit and allowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_normal_names() {
|
||||
for ok in ["create-post", "nightly_digest", "Greet", "x", "a.b"] {
|
||||
|
||||
67
crates/picloud-cli/src/linkstate.rs
Normal file
67
crates/picloud-cli/src/linkstate.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
//! `.picloud/` link state — gitignored, per-project metadata the project tool
|
||||
//! carries between CLI invocations. Today it holds just the bound-plan token:
|
||||
//! `pic plan` records the fingerprint of the live state it diffed against, and
|
||||
//! `pic apply` replays it so the server can refuse if the app moved underneath.
|
||||
//!
|
||||
//! All paths are relative to the manifest's directory (the project root).
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DIR: &str = ".picloud";
|
||||
const PLAN_FILE: &str = "plan.json";
|
||||
|
||||
/// The recorded result of the last `pic plan`, scoped to the app it was for.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanLink {
|
||||
/// App slug the token belongs to — guards against replaying a token from a
|
||||
/// different app if the manifest's `slug` changed.
|
||||
pub app: String,
|
||||
pub state_token: String,
|
||||
}
|
||||
|
||||
fn plan_path(base: &Path) -> PathBuf {
|
||||
base.join(DIR).join(PLAN_FILE)
|
||||
}
|
||||
|
||||
/// Record the bound-plan token for `app` under `base/.picloud/`.
|
||||
pub fn write_plan(base: &Path, app: &str, state_token: &str) -> Result<()> {
|
||||
let dir = base.join(DIR);
|
||||
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||
// Self-ignore: a `.gitignore` of `*` inside `.picloud/` keeps the whole
|
||||
// dir out of git regardless of the project root's `.gitignore` — so this
|
||||
// is safe even when reached via `pic plan`/`pull` (which, unlike `init`,
|
||||
// don't touch the root `.gitignore`).
|
||||
let ignore = dir.join(".gitignore");
|
||||
if !ignore.exists() {
|
||||
fs::write(&ignore, "*\n").context("writing .picloud/.gitignore")?;
|
||||
}
|
||||
let link = PlanLink {
|
||||
app: app.to_string(),
|
||||
state_token: state_token.to_string(),
|
||||
};
|
||||
let body = serde_json::to_vec_pretty(&link).context("encoding .picloud/plan.json")?;
|
||||
fs::write(plan_path(base), body).context("writing .picloud/plan.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the recorded plan token, if any. Returns `None` when absent or
|
||||
/// unreadable (treated as "no prior plan" — never an error).
|
||||
#[must_use]
|
||||
pub fn read_plan(base: &Path) -> Option<PlanLink> {
|
||||
let body = fs::read(plan_path(base)).ok()?;
|
||||
serde_json::from_slice(&body).ok()
|
||||
}
|
||||
|
||||
/// Remove the recorded plan token (best-effort) **iff it belongs to `app`**.
|
||||
/// Called after a successful apply consumes it, so the next apply requires a
|
||||
/// fresh plan — without clobbering a token recorded for a different app that
|
||||
/// happens to share the directory.
|
||||
pub fn clear_plan(base: &Path, app: &str) {
|
||||
if read_plan(base).is_some_and(|l| l.app == app) {
|
||||
let _ = fs::remove_file(plan_path(base));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
mod client;
|
||||
mod cmds;
|
||||
mod config;
|
||||
mod linkstate;
|
||||
mod manifest;
|
||||
mod output;
|
||||
|
||||
@@ -50,6 +51,12 @@ enum Cmd {
|
||||
cmd: AppsCmd,
|
||||
},
|
||||
|
||||
/// Group (org-tree) management.
|
||||
Groups {
|
||||
#[command(subcommand)]
|
||||
cmd: GroupsCmd,
|
||||
},
|
||||
|
||||
/// Script management.
|
||||
Scripts {
|
||||
#[command(subcommand)]
|
||||
@@ -159,7 +166,8 @@ enum Cmd {
|
||||
},
|
||||
|
||||
/// Reconcile the live app to a `picloud.toml` manifest in one
|
||||
/// transaction (additive: creates + updates).
|
||||
/// transaction (creates + updates; `--prune` also deletes resources
|
||||
/// absent from the manifest).
|
||||
Apply(ApplyArgs),
|
||||
|
||||
/// Diff a `picloud.toml` manifest against the live app and print the
|
||||
@@ -170,6 +178,15 @@ enum Cmd {
|
||||
/// (+ `scripts/<name>.rhai` sources) for declarative management with
|
||||
/// `pic plan` / `pic apply`.
|
||||
Pull(PullArgs),
|
||||
|
||||
/// Scaffold a new declarative project: a `picloud.toml` (minimal working
|
||||
/// app), `scripts/hello.rhai`, and a `.gitignore`. Offline; deploy with
|
||||
/// `pic plan` then `pic apply`.
|
||||
Init(InitArgs),
|
||||
|
||||
/// Show an app's resolved configuration. `--effective` lists secrets with
|
||||
/// values masked (`<set>`/`<unset>`), cross-referenced against the manifest.
|
||||
Config(ConfigArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -181,6 +198,18 @@ struct ApplyArgs {
|
||||
/// Secrets are never pruned.
|
||||
#[arg(long)]
|
||||
prune: bool,
|
||||
/// Skip the `--prune` confirmation prompt. Required to prune
|
||||
/// non-interactively (CI).
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
/// Skip the bound-plan staleness check (apply even if the app changed
|
||||
/// since the last `pic plan`).
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
|
||||
/// top of the base manifest before applying.
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -188,6 +217,10 @@ struct PlanArgs {
|
||||
/// Path to the manifest.
|
||||
#[arg(long, default_value = "picloud.toml")]
|
||||
file: PathBuf,
|
||||
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
|
||||
/// top of the base manifest before diffing.
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -197,6 +230,39 @@ struct PullArgs {
|
||||
/// Directory to write `picloud.toml` + `scripts/` into.
|
||||
#[arg(long, default_value = ".")]
|
||||
dir: PathBuf,
|
||||
/// Overwrite an existing `picloud.toml` (and colliding `scripts/*.rhai`).
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct InitArgs {
|
||||
/// App slug for the new project. Defaults to a slug derived from the
|
||||
/// target directory's name.
|
||||
slug: Option<String>,
|
||||
/// Directory to scaffold into.
|
||||
#[arg(long, default_value = ".")]
|
||||
dir: PathBuf,
|
||||
/// Display name for the app. Defaults to a title-cased slug.
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
/// Overwrite an existing `picloud.toml`.
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct ConfigArgs {
|
||||
/// Path to the manifest.
|
||||
#[arg(long, default_value = "picloud.toml")]
|
||||
file: PathBuf,
|
||||
/// Show the effective (resolved) config with secrets masked.
|
||||
#[arg(long)]
|
||||
effective: bool,
|
||||
/// Resolve against the `picloud.<env>.toml` overlay (per-env slug +
|
||||
/// secrets), matching `pic plan --env` / `pic apply --env`.
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -341,6 +407,9 @@ enum AppsCmd {
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
/// Parent group (slug or id). Defaults to the instance root.
|
||||
#[arg(long)]
|
||||
group: Option<String>,
|
||||
},
|
||||
|
||||
/// Show a single app, including the caller's role in it.
|
||||
@@ -363,6 +432,75 @@ enum AppsCmd {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum GroupsCmd {
|
||||
/// List all groups (flat).
|
||||
Ls,
|
||||
/// Render the group hierarchy as an indented tree.
|
||||
Tree,
|
||||
/// Create a new group. Omit `--parent` for a root-level group.
|
||||
Create {
|
||||
slug: String,
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
/// Parent group (slug or id).
|
||||
#[arg(long)]
|
||||
parent: Option<String>,
|
||||
},
|
||||
/// Show a group with its path, subgroups, and apps.
|
||||
Show { ident: String },
|
||||
/// Rename a group (name/description only — the slug is frozen).
|
||||
Rename {
|
||||
ident: String,
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
},
|
||||
/// Move a group under a new parent (`--to` slug/id, or omit for root).
|
||||
Reparent {
|
||||
ident: String,
|
||||
#[arg(long)]
|
||||
to: Option<String>,
|
||||
},
|
||||
/// Delete a group. Refused (409) if non-empty unless `--recursive`,
|
||||
/// which deletes child groups leaf-first (apps are never auto-deleted).
|
||||
Rm {
|
||||
ident: String,
|
||||
#[arg(long)]
|
||||
recursive: bool,
|
||||
},
|
||||
/// Manage a group's members (inherited down the tree).
|
||||
Members {
|
||||
#[command(subcommand)]
|
||||
cmd: GroupMembersCmd,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum GroupMembersCmd {
|
||||
/// List a group's members.
|
||||
Ls { group: String },
|
||||
/// Grant a member a role on the group.
|
||||
Add {
|
||||
group: String,
|
||||
user_id: String,
|
||||
#[arg(long, default_value = "viewer")]
|
||||
role: String,
|
||||
},
|
||||
/// Change a member's role.
|
||||
Set {
|
||||
group: String,
|
||||
user_id: String,
|
||||
#[arg(long)]
|
||||
role: String,
|
||||
},
|
||||
/// Remove a member from the group.
|
||||
Rm { group: String, user_id: String },
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum DomainsCmd {
|
||||
/// List the app's domain claims.
|
||||
@@ -1086,9 +1224,29 @@ async fn main() -> ExitCode {
|
||||
}
|
||||
Cmd::Logout => cmds::logout::run().await,
|
||||
Cmd::Whoami => cmds::whoami::run(mode).await,
|
||||
Cmd::Apply(args) => cmds::apply::run(&args.file, args.prune, mode).await,
|
||||
Cmd::Plan(args) => cmds::plan::run(&args.file, mode).await,
|
||||
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, mode).await,
|
||||
Cmd::Apply(args) => {
|
||||
cmds::apply::run(
|
||||
&args.file,
|
||||
args.env.as_deref(),
|
||||
args.prune,
|
||||
args.yes,
|
||||
args.force,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Plan(args) => cmds::plan::run(&args.file, args.env.as_deref(), mode).await,
|
||||
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, args.force, mode).await,
|
||||
Cmd::Config(args) => {
|
||||
cmds::config::run(&args.file, args.effective, args.env.as_deref(), mode).await
|
||||
}
|
||||
Cmd::Init(args) => cmds::init::run(
|
||||
&args.dir,
|
||||
args.slug.as_deref(),
|
||||
args.name.as_deref(),
|
||||
args.force,
|
||||
mode,
|
||||
),
|
||||
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,
|
||||
Cmd::Apps {
|
||||
cmd:
|
||||
@@ -1096,8 +1254,18 @@ async fn main() -> ExitCode {
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
group,
|
||||
},
|
||||
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref(), mode).await,
|
||||
} => {
|
||||
cmds::apps::create(
|
||||
&slug,
|
||||
name.as_deref(),
|
||||
description.as_deref(),
|
||||
group.as_deref(),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Apps {
|
||||
cmd: AppsCmd::Show { ident },
|
||||
} => cmds::apps::show(&ident, mode).await,
|
||||
@@ -1121,6 +1289,79 @@ async fn main() -> ExitCode {
|
||||
cmd: DomainsCmd::Rm { app, domain_id },
|
||||
},
|
||||
} => cmds::apps_domains::rm(&app, &domain_id).await,
|
||||
Cmd::Groups { cmd: GroupsCmd::Ls } => cmds::groups::ls(mode).await,
|
||||
Cmd::Groups {
|
||||
cmd: GroupsCmd::Tree,
|
||||
} => cmds::groups::tree(mode).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Create {
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
parent,
|
||||
},
|
||||
} => {
|
||||
cmds::groups::create(
|
||||
&slug,
|
||||
name.as_deref(),
|
||||
description.as_deref(),
|
||||
parent.as_deref(),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Groups {
|
||||
cmd: GroupsCmd::Show { ident },
|
||||
} => cmds::groups::show(&ident, mode).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Rename {
|
||||
ident,
|
||||
name,
|
||||
description,
|
||||
},
|
||||
} => cmds::groups::rename(&ident, name.as_deref(), description.as_deref(), mode).await,
|
||||
Cmd::Groups {
|
||||
cmd: GroupsCmd::Reparent { ident, to },
|
||||
} => cmds::groups::reparent(&ident, to.as_deref(), mode).await,
|
||||
Cmd::Groups {
|
||||
cmd: GroupsCmd::Rm { ident, recursive },
|
||||
} => cmds::groups::rm(&ident, recursive).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Members {
|
||||
cmd: GroupMembersCmd::Ls { group },
|
||||
},
|
||||
} => cmds::groups::members_ls(&group, mode).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Members {
|
||||
cmd:
|
||||
GroupMembersCmd::Add {
|
||||
group,
|
||||
user_id,
|
||||
role,
|
||||
},
|
||||
},
|
||||
} => cmds::groups::members_add(&group, &user_id, &role, mode).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Members {
|
||||
cmd:
|
||||
GroupMembersCmd::Set {
|
||||
group,
|
||||
user_id,
|
||||
role,
|
||||
},
|
||||
},
|
||||
} => cmds::groups::members_set(&group, &user_id, &role, mode).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Members {
|
||||
cmd: GroupMembersCmd::Rm { group, user_id },
|
||||
},
|
||||
} => cmds::groups::members_rm(&group, &user_id).await,
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Ls { app },
|
||||
} => cmds::scripts::ls(app.as_deref(), mode).await,
|
||||
|
||||
@@ -58,6 +58,81 @@ impl Manifest {
|
||||
pub fn to_toml(&self) -> Result<String> {
|
||||
toml::to_string_pretty(self).context("serializing manifest TOML")
|
||||
}
|
||||
|
||||
/// Load the base manifest, then (if `env` is set) merge the sparse
|
||||
/// `picloud.<env>.toml` overlay on top — the §4.1 base+overlay model
|
||||
/// where "an environment is an app". The overlay carries per-env slug +
|
||||
/// secrets; scripts/routes/triggers stay in the shared base. (Rich
|
||||
/// per-key `vars` resolution is Phase 3.)
|
||||
pub fn load_with_env(base_path: &Path, env: Option<&str>) -> Result<Self> {
|
||||
let mut base = Self::load(base_path)?;
|
||||
if let Some(env) = env {
|
||||
let path = overlay_path(base_path, env);
|
||||
let body = fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"reading overlay {} for env `{env}` (expected next to the base manifest)",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let overlay: ManifestOverlay = toml::from_str(&body)
|
||||
.with_context(|| format!("parsing overlay {}", path.display()))?;
|
||||
base.apply_overlay(overlay);
|
||||
}
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
/// Merge a sparse overlay onto this manifest: overlay `app.slug`/`name`
|
||||
/// replace the base's; overlay secret names union into the base set.
|
||||
fn apply_overlay(&mut self, overlay: ManifestOverlay) {
|
||||
if let Some(slug) = overlay.app.slug {
|
||||
self.app.slug = slug;
|
||||
}
|
||||
if let Some(name) = overlay.app.name {
|
||||
self.app.name = name;
|
||||
}
|
||||
for n in overlay.secrets.names {
|
||||
if !self.secrets.names.contains(&n) {
|
||||
self.secrets.names.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `picloud.toml` → `picloud.<env>.toml`, alongside the base (works for a
|
||||
/// custom `--file` too: `custom.toml` → `custom.<env>.toml`).
|
||||
fn overlay_path(base_path: &Path, env: &str) -> std::path::PathBuf {
|
||||
let parent = base_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let name = base_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| MANIFEST_FILE.to_string());
|
||||
let stem = name.strip_suffix(".toml").unwrap_or(&name);
|
||||
parent.join(format!("{stem}.{env}.toml"))
|
||||
}
|
||||
|
||||
/// A sparse per-environment overlay (`picloud.<env>.toml`). Only the fields
|
||||
/// that vary per environment today — slug/name and secret names.
|
||||
///
|
||||
/// `deny_unknown_fields`: an overlay can carry *only* `[app]` and `[secrets]`.
|
||||
/// Scripts/routes/triggers belong in the shared base manifest, so a
|
||||
/// `[[scripts]]`/`[[routes]]`/`[[triggers]]` table (or a typo'd key) in an
|
||||
/// overlay is a mistake — error loudly rather than silently dropping it.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestOverlay {
|
||||
#[serde(default)]
|
||||
pub app: OverlayApp,
|
||||
#[serde(default)]
|
||||
pub secrets: ManifestSecrets,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OverlayApp {
|
||||
#[serde(default)]
|
||||
pub slug: Option<String>,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -84,6 +159,13 @@ pub struct ManifestScript {
|
||||
/// Per-script sandbox overrides; omitted entirely when no knob is set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
/// Three-state lifecycle (§4.3): `false` deploys the script inert (not
|
||||
/// invocable). Omitted ⇒ active; only serialized when disabled.
|
||||
#[serde(
|
||||
default = "picloud_shared::default_true",
|
||||
skip_serializing_if = "is_true"
|
||||
)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -102,6 +184,13 @@ pub struct ManifestRoute {
|
||||
pub path: String,
|
||||
#[serde(default, skip_serializing_if = "is_sync")]
|
||||
pub dispatch_mode: DispatchMode,
|
||||
/// Three-state lifecycle (§4.3): `false` deploys the route inert (404).
|
||||
/// Omitted ⇒ active; only serialized when disabled.
|
||||
#[serde(
|
||||
default = "picloud_shared::default_true",
|
||||
skip_serializing_if = "is_true"
|
||||
)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
|
||||
@@ -244,6 +333,11 @@ fn is_sync(mode: &DispatchMode) -> bool {
|
||||
*mode == DispatchMode::Sync
|
||||
}
|
||||
|
||||
/// Skip-serialize helper: `enabled` defaults true, so only emit it when false.
|
||||
fn is_true(b: &bool) -> bool {
|
||||
*b
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
"UTC".to_string()
|
||||
}
|
||||
@@ -268,6 +362,7 @@ mod tests {
|
||||
timeout_seconds: Some(10),
|
||||
memory_limit_mb: Some(256),
|
||||
sandbox: None,
|
||||
enabled: true,
|
||||
},
|
||||
ManifestScript {
|
||||
name: "lib".into(),
|
||||
@@ -280,6 +375,7 @@ mod tests {
|
||||
max_operations: Some(5_000_000),
|
||||
..ScriptSandbox::empty()
|
||||
}),
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
routes: vec![ManifestRoute {
|
||||
@@ -291,6 +387,7 @@ mod tests {
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/posts".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
}],
|
||||
triggers: ManifestTriggers {
|
||||
cron: vec![CronTriggerSpec {
|
||||
@@ -337,6 +434,70 @@ mod tests {
|
||||
);
|
||||
// Module kind IS non-default → emitted.
|
||||
assert!(text.contains("kind = \"module\""), "got:\n{text}");
|
||||
// `enabled` defaults true → omitted for the active script/route, but
|
||||
// the disabled `lib` script emits `enabled = false`.
|
||||
assert!(
|
||||
text.contains("enabled = false"),
|
||||
"a disabled entity must emit enabled:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("enabled = true"),
|
||||
"active entities must omit the default:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_merges_slug_and_unions_secrets() {
|
||||
let mut m = sample(); // slug "blog", secrets ["STRIPE_KEY"]
|
||||
let overlay: ManifestOverlay = toml::from_str(
|
||||
"[app]\nslug = \"blog-staging\"\n\n[secrets]\nnames = [\"STRIPE_KEY\", \"STAGING_ONLY\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
m.apply_overlay(overlay);
|
||||
assert_eq!(m.app.slug, "blog-staging", "overlay slug wins");
|
||||
assert_eq!(
|
||||
m.app.name, "My Blog",
|
||||
"base name kept when overlay omits it"
|
||||
);
|
||||
assert_eq!(
|
||||
m.secrets.names,
|
||||
vec!["STRIPE_KEY".to_string(), "STAGING_ONLY".to_string()],
|
||||
"secrets union, no dupes"
|
||||
);
|
||||
// Scripts/routes come from the base, untouched by the overlay.
|
||||
assert_eq!(m.scripts.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_rejects_non_overlay_tables() {
|
||||
// An overlay carries only [app]/[secrets]. Scripts/routes/triggers
|
||||
// belong in the shared base, so a `[[scripts]]` table (or a typo'd
|
||||
// key) in an overlay must error loudly, not be silently dropped.
|
||||
let err = toml::from_str::<ManifestOverlay>(
|
||||
"[app]\nslug = \"blog-staging\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n",
|
||||
)
|
||||
.expect_err("overlay with [[scripts]] must be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("scripts") || err.to_string().contains("unknown"),
|
||||
"error should point at the offending table: {err}"
|
||||
);
|
||||
|
||||
// Typo'd key inside [app] is likewise rejected.
|
||||
toml::from_str::<ManifestOverlay>("[app]\nslag = \"oops\"\n")
|
||||
.expect_err("overlay with a typo'd [app] key must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_path_derivation() {
|
||||
assert_eq!(
|
||||
overlay_path(Path::new("proj/picloud.toml"), "staging"),
|
||||
Path::new("proj/picloud.staging.toml")
|
||||
);
|
||||
assert_eq!(
|
||||
overlay_path(Path::new("custom.toml"), "prod"),
|
||||
Path::new("custom.prod.toml")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -18,8 +18,13 @@ mod api_keys;
|
||||
mod apply;
|
||||
mod apps;
|
||||
mod auth;
|
||||
mod config;
|
||||
mod dead_letters;
|
||||
mod email_queue;
|
||||
mod enabled;
|
||||
mod env_overlay;
|
||||
mod groups;
|
||||
mod init;
|
||||
mod invoke;
|
||||
mod logs;
|
||||
mod output;
|
||||
@@ -30,4 +35,5 @@ mod roles;
|
||||
mod routes;
|
||||
mod scripts;
|
||||
mod secrets;
|
||||
mod staleness;
|
||||
mod triggers;
|
||||
|
||||
@@ -44,6 +44,35 @@ pub struct UserGuard {
|
||||
user_id: String,
|
||||
}
|
||||
|
||||
/// Deletes a group on drop (best-effort). The group must be empty by then
|
||||
/// — register an `AppGuard`/child `GroupGuard` *after* this one so the
|
||||
/// child drops (deletes) first, leaving an empty node here.
|
||||
pub struct GroupGuard {
|
||||
url: String,
|
||||
token: String,
|
||||
slug: String,
|
||||
}
|
||||
|
||||
impl GroupGuard {
|
||||
pub fn new(url: &str, token: &str, slug: &str) -> Self {
|
||||
Self {
|
||||
url: url.to_string(),
|
||||
token: token.to_string(),
|
||||
slug: slug.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GroupGuard {
|
||||
fn drop(&mut self) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let _ = client
|
||||
.delete(format!("{}/api/v1/admin/groups/{}", self.url, self.slug))
|
||||
.bearer_auth(&self.token)
|
||||
.send();
|
||||
}
|
||||
}
|
||||
|
||||
impl UserGuard {
|
||||
pub fn new(url: &str, token: &str, user_id: &str) -> Self {
|
||||
Self {
|
||||
|
||||
57
crates/picloud-cli/tests/config.rs
Normal file
57
crates/picloud-cli/tests/config.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
//! `pic config --effective` — masked secret resolution against the manifest.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use predicates::prelude::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn config_effective_masks_and_classifies_secrets() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("config");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(
|
||||
&manifest_path,
|
||||
format!("[app]\nslug = \"{slug}\"\nname = \"Cfg\"\n\n[secrets]\nnames = [\"API_KEY\"]\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Declared but not pushed → masked + flagged unset; value never shown.
|
||||
common::pic_as(&env)
|
||||
.args(["config", "--effective", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("API_KEY"))
|
||||
.stdout(predicate::str::contains("<unset>"))
|
||||
.stdout(predicate::str::contains("not pushed"));
|
||||
|
||||
// Push it → now masked as <set> / managed, still never the value.
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--app", &slug, "API_KEY"])
|
||||
.write_stdin("super-secret-value")
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["config", "--effective", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("<set>"))
|
||||
.stdout(predicate::str::contains("managed"))
|
||||
.stdout(predicate::str::contains("super-secret-value").not());
|
||||
}
|
||||
@@ -135,7 +135,7 @@ fn prune_refuses_to_orphan_email_trigger() {
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.arg("--prune")
|
||||
.args(["--prune", "--yes"])
|
||||
.output()
|
||||
.expect("apply --prune");
|
||||
assert!(
|
||||
|
||||
226
crates/picloud-cli/tests/enabled.rs
Normal file
226
crates/picloud-cli/tests/enabled.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
//! `enabled` three-state lifecycle, end to end: disabling a script via the
|
||||
//! manifest makes it non-invocable (404 on the execute-by-id bypass), and
|
||||
//! re-enabling restores it — proving the data path + runtime honoring.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn disabling_a_script_makes_it_uninvocable_then_reenable() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("enabled");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/hello.rhai"), "\"hi\"").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
let manifest = |enabled_line: &str| {
|
||||
format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Enabled\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n{enabled_line}"
|
||||
)
|
||||
};
|
||||
|
||||
// Active → invocable.
|
||||
fs::write(&manifest_path, manifest("")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
let id = script_id(&env, &slug, "hello");
|
||||
assert_eq!(invoke_status(&env, &id), 200, "active script must invoke");
|
||||
|
||||
// Disabled → 404 (not invocable), but still deployed (re-pull would show it).
|
||||
fs::write(&manifest_path, manifest("enabled = false\n")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
assert_eq!(
|
||||
invoke_status(&env, &id),
|
||||
404,
|
||||
"disabled script must 404 on execute-by-id"
|
||||
);
|
||||
|
||||
// Re-enabled → invocable again.
|
||||
fs::write(&manifest_path, manifest("enabled = true\n")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
assert_eq!(
|
||||
invoke_status(&env, &id),
|
||||
200,
|
||||
"re-enabled script must invoke"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn disabling_a_route_makes_it_404_then_reenable() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("enbl-route");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
// The app must claim a Host before its routes are reachable (two-phase
|
||||
// dispatch: Host → app → route). A unique strict host avoids colliding
|
||||
// with other tests' instance-global claims.
|
||||
let host = format!("{slug}.test");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "domains", "add", &slug, &host])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/hello.rhai"), "\"hi\"").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
let manifest = |enabled_line: &str| {
|
||||
format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"EnabledRoute\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n\n\
|
||||
[[routes]]\nscript = \"hello\"\nmethod = \"GET\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/hello\"\n{enabled_line}"
|
||||
)
|
||||
};
|
||||
|
||||
// Active → the route serves.
|
||||
fs::write(&manifest_path, manifest("")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
assert_eq!(
|
||||
route_status(&env, &host, "/hello"),
|
||||
200,
|
||||
"active route serves"
|
||||
);
|
||||
|
||||
// Disabled → 404, indistinguishable from absent.
|
||||
fs::write(&manifest_path, manifest("enabled = false\n")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
assert_eq!(
|
||||
route_status(&env, &host, "/hello"),
|
||||
404,
|
||||
"disabled route must 404"
|
||||
);
|
||||
|
||||
// Re-enabled → serves again.
|
||||
fs::write(&manifest_path, manifest("enabled = true\n")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
assert_eq!(
|
||||
route_status(&env, &host, "/hello"),
|
||||
200,
|
||||
"re-enabled route serves"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn enabled_route_to_disabled_script_404s_flatly() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("enbl-os");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
let host = format!("{slug}.test");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "domains", "add", &slug, &host])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/hello.rhai"), "\"hi\"").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
// Route stays enabled; the SCRIPT it binds is disabled (route-on/script-off).
|
||||
fs::write(
|
||||
&manifest_path,
|
||||
format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"OS\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\nenabled = false\n\n\
|
||||
[[routes]]\nscript = \"hello\"\nmethod = \"GET\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/hello\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
|
||||
// 404, and the body must be the flat "no route matches" form — never the
|
||||
// internal script id (no info leak; indistinguishable from absent).
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.get(format!("{}/hello", env.url))
|
||||
.header(reqwest::header::HOST, &host)
|
||||
.send()
|
||||
.unwrap();
|
||||
assert_eq!(resp.status().as_u16(), 404);
|
||||
let body = resp.text().unwrap();
|
||||
assert!(body.contains("no route matches"), "flat 404 body: {body}");
|
||||
}
|
||||
|
||||
fn apply(env: &common::TestEnv, manifest_path: &Path) {
|
||||
common::pic_as(env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
/// GET a user route under an explicit `Host` (the claimed domain) and return
|
||||
/// the HTTP status code.
|
||||
fn route_status(env: &common::TestEnv, host: &str, path: &str) -> u16 {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
client
|
||||
.get(format!("{}{path}", env.url))
|
||||
.header(reqwest::header::HOST, host)
|
||||
.send()
|
||||
.unwrap()
|
||||
.status()
|
||||
.as_u16()
|
||||
}
|
||||
|
||||
/// Resolve a script's id via the admin API (the manifest carries no ids).
|
||||
fn script_id(env: &common::TestEnv, slug: &str, name: &str) -> String {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let scripts: Vec<Value> = client
|
||||
.get(format!("{}/api/v1/admin/scripts?app={slug}", env.url))
|
||||
.bearer_auth(&env.token)
|
||||
.send()
|
||||
.unwrap()
|
||||
.json()
|
||||
.unwrap();
|
||||
scripts
|
||||
.into_iter()
|
||||
.find(|s| s["name"] == name)
|
||||
.and_then(|s| s["id"].as_str().map(String::from))
|
||||
.expect("script id")
|
||||
}
|
||||
|
||||
/// POST the execute-by-id bypass and return the HTTP status code.
|
||||
fn invoke_status(env: &common::TestEnv, id: &str) -> u16 {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
client
|
||||
.post(format!("{}/api/v1/execute/{id}", env.url))
|
||||
.bearer_auth(&env.token)
|
||||
.body("{}")
|
||||
.send()
|
||||
.unwrap()
|
||||
.status()
|
||||
.as_u16()
|
||||
}
|
||||
76
crates/picloud-cli/tests/env_overlay.rs
Normal file
76
crates/picloud-cli/tests/env_overlay.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! Env-scoped overlays (§4.1): `pic apply --env <E>` merges the sparse
|
||||
//! `picloud.<env>.toml` (per-env slug) onto the base and deploys to that
|
||||
//! environment's app — leaving the base app untouched.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
fn scripts_ls(env: &common::TestEnv, slug: &str) -> String {
|
||||
String::from_utf8(
|
||||
common::pic_as(env)
|
||||
.args(["scripts", "ls", "--app", slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_env_overlay_targets_the_env_app() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let base_slug = common::unique_slug("ovl");
|
||||
let staging_slug = format!("{base_slug}-staging");
|
||||
for s in [&base_slug, &staging_slug] {
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", s])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
let _g1 = AppGuard::new(&env.url, &env.token, &base_slug);
|
||||
let _g2 = AppGuard::new(&env.url, &env.token, &staging_slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/hello.rhai"), "\"hi\"").unwrap();
|
||||
let base = dir.path().join("picloud.toml");
|
||||
fs::write(
|
||||
&base,
|
||||
format!(
|
||||
"[app]\nslug = \"{base_slug}\"\nname = \"Ovl\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
// Overlay redirects to the staging app.
|
||||
fs::write(
|
||||
dir.path().join("picloud.staging.toml"),
|
||||
format!("[app]\nslug = \"{staging_slug}\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Apply to staging only.
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&base)
|
||||
.args(["--env", "staging"])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
assert!(
|
||||
scripts_ls(&env, &staging_slug).contains("hello"),
|
||||
"overlay apply must deploy to the staging app"
|
||||
);
|
||||
assert!(
|
||||
!scripts_ls(&env, &base_slug).contains("hello"),
|
||||
"the base app must be untouched by an --env apply"
|
||||
);
|
||||
}
|
||||
179
crates/picloud-cli/tests/groups.rs
Normal file
179
crates/picloud-cli/tests/groups.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
//! Phase-2 groups, end to end via `pic`: tree CRUD, delete=RESTRICT,
|
||||
//! reparent cycle rejection, and the headline invariant — a `group_admin`
|
||||
//! on an ancestor group can act on an app it is NOT a direct member of
|
||||
//! (inherited membership), and loses that access the instant the group
|
||||
//! grant is revoked.
|
||||
|
||||
use predicates::prelude::*;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard};
|
||||
use crate::common::member;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn group_tree_create_show_and_delete_restrict() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let acme = common::unique_slug("g-acme");
|
||||
let team = common::unique_slug("g-team");
|
||||
let app = common::unique_slug("g-app");
|
||||
|
||||
// Root-level group, then a subgroup under it.
|
||||
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
let _g_team = GroupGuard::new(&env.url, &env.token, &team);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &team, "--parent", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// An app under the subgroup.
|
||||
let _app = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &team])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// `groups show team` lists the app.
|
||||
let out = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "show", &team])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.contains(&app),
|
||||
"group detail should list its app:\n{out}"
|
||||
);
|
||||
|
||||
// delete=RESTRICT: acme has a subgroup → refused.
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "rm", &acme])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("409").or(predicate::str::contains("subgroup")));
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn reparent_into_own_descendant_is_rejected() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let parent = common::unique_slug("g-cyc-p");
|
||||
let child = common::unique_slug("g-cyc-c");
|
||||
|
||||
let _g_parent = GroupGuard::new(&env.url, &env.token, &parent);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &parent])
|
||||
.assert()
|
||||
.success();
|
||||
let _g_child = GroupGuard::new(&env.url, &env.token, &child);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &child, "--parent", &parent])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Moving the parent under its own child would form a cycle → refused.
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "reparent", &parent, "--to", &child])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("409").or(predicate::str::contains("descendant")));
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn inherited_group_admin_can_deploy_then_revoke() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let acme = common::unique_slug("g-inh");
|
||||
let app = common::unique_slug("g-inh-app");
|
||||
|
||||
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
let _app = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// A fresh Member with NO app membership.
|
||||
let m = member::member_user(fx, &common::unique_username("inh"));
|
||||
let member_env = common::custom_env(&fx.url, &m.token);
|
||||
common::seed_credentials(&member_env, &m.username);
|
||||
let fixture = common::fixture_path("hello.rhai");
|
||||
|
||||
// Baseline: without any grant, deploy is forbidden.
|
||||
common::pic_as(&member_env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
fixture.to_str().unwrap(),
|
||||
"--app",
|
||||
&app,
|
||||
])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("HTTP 403"));
|
||||
|
||||
// Grant group_admin on the ANCESTOR group (no app_members row).
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"groups",
|
||||
"members",
|
||||
"add",
|
||||
&acme,
|
||||
&m.id,
|
||||
"--role",
|
||||
"app_admin",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Inherited: the member can now deploy to the app it never joined.
|
||||
// (Deploy prints a KvBlock — assert on the script name + create action,
|
||||
// not a prose string.)
|
||||
common::pic_as(&member_env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
fixture.to_str().unwrap(),
|
||||
"--app",
|
||||
&app,
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("hello").and(predicate::str::contains("created")));
|
||||
|
||||
// Revoke the group grant → access drops immediately (no cache lag).
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "members", "rm", &acme, &m.id])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&member_env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
fixture.to_str().unwrap(),
|
||||
"--app",
|
||||
&app,
|
||||
])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("HTTP 403"));
|
||||
}
|
||||
99
crates/picloud-cli/tests/init.rs
Normal file
99
crates/picloud-cli/tests/init.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
//! `pic init` journey — offline scaffolding, no server/DB needed (so these
|
||||
//! tests are NOT gated on `DATABASE_URL` and never touch `common::fixture`).
|
||||
|
||||
use std::fs;
|
||||
|
||||
use assert_cmd::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn pic() -> Command {
|
||||
Command::cargo_bin("pic").expect("pic binary")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_scaffolds_a_deployable_project() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
pic()
|
||||
.current_dir(dir.path())
|
||||
.args(["init", "demo-app"])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let toml = fs::read_to_string(dir.path().join("picloud.toml")).unwrap();
|
||||
assert!(toml.contains("slug = \"demo-app\""), "got:\n{toml}");
|
||||
assert!(
|
||||
toml.contains("name = \"Demo App\""),
|
||||
"name defaults to title-cased slug:\n{toml}"
|
||||
);
|
||||
assert!(
|
||||
dir.path().join("scripts/hello.rhai").exists(),
|
||||
"scaffold writes the example script"
|
||||
);
|
||||
let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
|
||||
assert!(
|
||||
gitignore.lines().any(|l| l.trim() == ".picloud/"),
|
||||
"init must gitignore .picloud/:\n{gitignore}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_derives_slug_from_a_not_yet_created_dir() {
|
||||
// The natural `pic init --dir new-project` flow: the target doesn't exist
|
||||
// yet, and the slug is derived from its name (not via canonicalize, which
|
||||
// would fail on a missing path).
|
||||
let parent = TempDir::new().unwrap();
|
||||
let target = parent.path().join("new-project");
|
||||
pic()
|
||||
.args(["init", "--dir"])
|
||||
.arg(&target)
|
||||
.assert()
|
||||
.success();
|
||||
let toml = fs::read_to_string(target.join("picloud.toml")).unwrap();
|
||||
assert!(
|
||||
toml.contains("slug = \"new-project\""),
|
||||
"slug should derive from the dir name:\n{toml}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_refuses_to_overwrite_without_force() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
pic()
|
||||
.current_dir(dir.path())
|
||||
.args(["init", "demo-app"])
|
||||
.assert()
|
||||
.success();
|
||||
// A second run must refuse rather than clobber edits.
|
||||
pic()
|
||||
.current_dir(dir.path())
|
||||
.args(["init", "demo-app"])
|
||||
.assert()
|
||||
.failure();
|
||||
// `--force` overrides.
|
||||
pic()
|
||||
.current_dir(dir.path())
|
||||
.args(["init", "demo-app", "--force"])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_appends_to_an_existing_gitignore_once() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::write(dir.path().join(".gitignore"), "target/\n").unwrap();
|
||||
pic()
|
||||
.current_dir(dir.path())
|
||||
.args(["init", "demo-app"])
|
||||
.assert()
|
||||
.success();
|
||||
let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
|
||||
assert!(
|
||||
gitignore.contains("target/"),
|
||||
"must preserve existing rules"
|
||||
);
|
||||
assert_eq!(
|
||||
gitignore.matches(".picloud/").count(),
|
||||
1,
|
||||
"must add the ignore exactly once:\n{gitignore}"
|
||||
);
|
||||
}
|
||||
@@ -72,11 +72,12 @@ fn prune_deletes_stale_resources() {
|
||||
"additive apply must not delete"
|
||||
);
|
||||
|
||||
// Prune apply removes `drop` and its route.
|
||||
// Prune apply removes `drop` and its route. `--yes` skips the
|
||||
// confirmation prompt (this test runs non-interactively).
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.arg("--prune")
|
||||
.args(["--prune", "--yes"])
|
||||
.output()
|
||||
.expect("apply --prune");
|
||||
assert!(
|
||||
@@ -109,3 +110,68 @@ fn prune_deletes_stale_resources() {
|
||||
"plan should be clean after prune:\n{p}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The prune confirmation gate: `--prune` without `--yes`, run
|
||||
/// non-interactively (no TTY, as in CI), must refuse and delete nothing.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn prune_without_yes_refuses_noninteractively() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("prune-gate");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/keep.rhai"), "let x = 1; x").unwrap();
|
||||
fs::write(dir.path().join("scripts/drop.rhai"), "let y = 2; y").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
|
||||
// Deploy two scripts, then drop one from the manifest.
|
||||
let v1 = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Gate Test\"\n\n\
|
||||
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n\n\
|
||||
[[scripts]]\nname = \"drop\"\nfile = \"scripts/drop.rhai\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &v1).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
let v2 = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Gate Test\"\n\n\
|
||||
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &v2).unwrap();
|
||||
|
||||
// `--prune` with no `--yes` and no TTY → refuse, non-zero exit.
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.arg("--prune")
|
||||
.output()
|
||||
.expect("apply --prune");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"prune without --yes must refuse non-interactively"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
err.contains("--yes"),
|
||||
"refusal should mention --yes:\n{err}"
|
||||
);
|
||||
|
||||
// The dropped script must still be there — the gate blocked the delete.
|
||||
let s = scripts_ls(&env, &slug);
|
||||
assert!(
|
||||
s.contains("drop"),
|
||||
"refused prune must not delete anything:\n{s}"
|
||||
);
|
||||
}
|
||||
|
||||
82
crates/picloud-cli/tests/staleness.rs
Normal file
82
crates/picloud-cli/tests/staleness.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
//! Bound-plan staleness: `pic plan` records a state token under `.picloud/`,
|
||||
//! and a later `pic apply` refuses (without `--force`) if the app changed
|
||||
//! out-of-band since the plan was reviewed.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_refuses_when_state_moved_since_plan() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("stale");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/hello.rhai"), "let x = 1; x").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Stale\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
// Establish hello, then plan — the plan records the current state token.
|
||||
apply(&env, &manifest_path).assert().success();
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
assert!(
|
||||
dir.path().join(".picloud/plan.json").exists(),
|
||||
"plan must record the bound-plan token under .picloud/"
|
||||
);
|
||||
|
||||
// Out-of-band change: deploy an extra script the manifest doesn't mention.
|
||||
fs::write(dir.path().join("scripts/sneaky.rhai"), "let y = 2; y").unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/sneaky.rhai"))
|
||||
.args(["--app", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Apply must now refuse — the live state no longer matches the plan.
|
||||
let refused = apply(&env, &manifest_path).output().expect("apply");
|
||||
assert!(
|
||||
!refused.status.success(),
|
||||
"apply must refuse after out-of-band change"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&refused.stderr);
|
||||
assert!(
|
||||
err.contains("changed since") || err.contains("pic plan"),
|
||||
"refusal should explain the staleness:\n{err}"
|
||||
);
|
||||
|
||||
// `--force` bypasses the check and applies anyway.
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.arg("--force")
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
fn apply(env: &common::TestEnv, manifest_path: &std::path::Path) -> assert_cmd::Command {
|
||||
let mut cmd = common::pic_as(env);
|
||||
cmd.args(["apply", "--file"]).arg(manifest_path);
|
||||
cmd
|
||||
}
|
||||
@@ -12,26 +12,28 @@ use picloud_executor_core::{Engine, Limits};
|
||||
use picloud_manager_core::{
|
||||
admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api,
|
||||
apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
|
||||
dev_emails_router, email_inbound_router, files_admin_router, kv_admin_router, migrations,
|
||||
require_authenticated, route_admin_router, secrets_router, topics_router, triggers_router,
|
||||
AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository,
|
||||
AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository,
|
||||
AppMembersState, AppRepository, ApplyService, AppsState, AuthState, AuthzRepo, DeadLetterRepo,
|
||||
DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl, EmailInboundState,
|
||||
EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, HttpConfig,
|
||||
HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter,
|
||||
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||
dev_emails_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router,
|
||||
migrations, require_authenticated, route_admin_router, secrets_router, topics_router,
|
||||
triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState,
|
||||
AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository,
|
||||
AppMembersRepository, AppMembersState, AppRepository, ApplyService, AppsState, AuthState,
|
||||
AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl,
|
||||
EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl,
|
||||
FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState, HttpConfig, HttpServiceImpl,
|
||||
InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, OutboxRepo,
|
||||
PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
||||
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
|
||||
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo,
|
||||
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
|
||||
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
|
||||
PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
|
||||
PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo,
|
||||
PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver,
|
||||
RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig,
|
||||
SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig,
|
||||
TriggerRepo, TriggersState, UsersServiceConfig, UsersServiceImpl,
|
||||
PostgresExecutionLogSink, PostgresGroupMembersRepository, PostgresGroupRepository,
|
||||
PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository,
|
||||
PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo,
|
||||
PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
|
||||
RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl,
|
||||
SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo,
|
||||
TriggersState, UsersServiceConfig, UsersServiceImpl,
|
||||
};
|
||||
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
|
||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||
@@ -109,6 +111,10 @@ pub async fn build_app(
|
||||
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
|
||||
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
||||
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||
let groups_repo: Arc<dyn GroupRepository> =
|
||||
Arc::new(PostgresGroupRepository::new(pool.clone()));
|
||||
let group_members_repo: Arc<dyn GroupMembersRepository> =
|
||||
Arc::new(PostgresGroupMembersRepository::new(pool.clone()));
|
||||
let domains_repo: Arc<dyn AppDomainRepository> =
|
||||
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
|
||||
// The Postgres app_members repo implements both `AppMembersRepository`
|
||||
@@ -513,6 +519,7 @@ pub async fn build_app(
|
||||
routes: route_repo,
|
||||
domain_table: app_domain_table.clone(),
|
||||
authz: authz.clone(),
|
||||
groups: groups_repo.clone(),
|
||||
};
|
||||
|
||||
// Audit 2026-06-11 H-B1 — login DoS defenses. PICLOUD_LOGIN_ARGON2_PARALLELISM
|
||||
@@ -552,6 +559,13 @@ pub async fn build_app(
|
||||
members,
|
||||
authz: authz.clone(),
|
||||
};
|
||||
let groups_state = GroupsState {
|
||||
groups: groups_repo.clone(),
|
||||
group_members: group_members_repo.clone(),
|
||||
apps: apps_state.apps.clone(),
|
||||
users: auth.users.clone(),
|
||||
authz: authz.clone(),
|
||||
};
|
||||
let app_users_admin_state = picloud_manager_core::AppUsersState {
|
||||
apps: apps_state.apps.clone(),
|
||||
authz: authz.clone(),
|
||||
@@ -574,6 +588,7 @@ pub async fn build_app(
|
||||
.merge(admins_router(admins_state))
|
||||
.merge(apps_router(apps_state))
|
||||
.merge(app_members_router(app_members_state))
|
||||
.merge(groups_router(groups_state))
|
||||
.merge(picloud_manager_core::app_users_router(
|
||||
app_users_admin_state,
|
||||
))
|
||||
|
||||
@@ -9,7 +9,7 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppId;
|
||||
use crate::{AppId, GroupId};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct App {
|
||||
@@ -20,6 +20,9 @@ pub struct App {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Parent group in the org tree (Phase 2). Every app has a parent
|
||||
/// from day one (§9 backfill seeds the instance root group).
|
||||
pub group_id: GroupId,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -98,6 +98,30 @@ impl AppRole {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Authority rank: higher = more authority. Used to fold the highest
|
||||
/// effective role across an app's own membership and every ancestor
|
||||
/// group membership (hierarchy-aware RBAC). Defined explicitly rather
|
||||
/// than via a derived `Ord` because the variant declaration order
|
||||
/// (`AppAdmin` first) is the reverse of authority order.
|
||||
#[must_use]
|
||||
pub const fn precedence(self) -> u8 {
|
||||
match self {
|
||||
Self::AppAdmin => 3,
|
||||
Self::Editor => 2,
|
||||
Self::Viewer => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The more-authoritative of two roles. `app_admin > editor > viewer`.
|
||||
#[must_use]
|
||||
pub fn max(self, other: Self) -> Self {
|
||||
if self.precedence() >= other.precedence() {
|
||||
self
|
||||
} else {
|
||||
other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// API-key scope. Exactly seven values; new scopes need a blueprint
|
||||
|
||||
31
crates/shared/src/group.rs
Normal file
31
crates/shared/src/group.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
//! Groups: a GitLab-like, single-parent org tree ABOVE apps (Phase 2).
|
||||
//!
|
||||
//! Groups are a pure org / RBAC / UI container — they own no resources
|
||||
//! (scripts/secrets stay app-owned). A `group_admin` on any ancestor is
|
||||
//! implicitly app_admin on every app/subgroup beneath it; resolution
|
||||
//! takes the highest effective role across the app's own membership and
|
||||
//! all ancestor group memberships.
|
||||
//!
|
||||
//! See docs/design/groups-and-project-tool.md §5.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::GroupId;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Group {
|
||||
pub id: GroupId,
|
||||
/// `None` for a root node. Single-parent keeps the tree acyclic.
|
||||
pub parent_id: Option<GroupId>,
|
||||
/// Instance-global identifier, frozen at creation (a rename/reparent
|
||||
/// never rewrites it — the deployment key stays stable).
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Per-subtree structure version, bumped on every structural mutation
|
||||
/// (reparent/rename/delete). Not an authz input.
|
||||
pub structure_version: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -57,3 +57,4 @@ id_type!(TriggerId);
|
||||
id_type!(AppUserId);
|
||||
id_type!(InvitationId);
|
||||
id_type!(QueueMessageId);
|
||||
id_type!(GroupId);
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod events;
|
||||
pub mod exec_summary;
|
||||
pub mod execution_log;
|
||||
pub mod files;
|
||||
pub mod group;
|
||||
pub mod http;
|
||||
pub mod ids;
|
||||
pub mod inbox;
|
||||
@@ -39,6 +40,14 @@ pub mod users;
|
||||
pub mod validator;
|
||||
pub mod version;
|
||||
|
||||
/// serde `default` for `enabled`-style boolean fields that should default to
|
||||
/// `true` when absent from the wire (bool's own `Default` is `false`). Shared
|
||||
/// by `Script`/`Route`, the apply `Bundle` types, and the CLI manifest.
|
||||
#[must_use]
|
||||
pub fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub use app::{App, AppDomain, DomainShape};
|
||||
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
|
||||
pub use crypto::{decrypt, encrypt, CryptoError, EncryptResult, MasterKey, MasterKeyError};
|
||||
@@ -54,10 +63,11 @@ pub use files::{
|
||||
FileUpdate, FilesError, FilesListPage, FilesService, NewFile, NoopFilesService,
|
||||
SAFE_RENDER_FALLBACK,
|
||||
};
|
||||
pub use group::Group;
|
||||
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
|
||||
pub use ids::{
|
||||
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, InvitationId, QueueMessageId, RequestId,
|
||||
ScriptId, TriggerId,
|
||||
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, QueueMessageId,
|
||||
RequestId, ScriptId, TriggerId,
|
||||
};
|
||||
pub use inbox::{
|
||||
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
|
||||
|
||||
@@ -99,5 +99,11 @@ pub struct Route {
|
||||
#[serde(default)]
|
||||
pub dispatch_mode: DispatchMode,
|
||||
|
||||
/// Three-state lifecycle (§4.3): `false` means the route is deployed but
|
||||
/// inert — it is dropped from the compiled match table, so a request 404s
|
||||
/// indistinguishably from an absent route. Defaults `true`.
|
||||
#[serde(default = "crate::default_true")]
|
||||
pub enabled: bool,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -122,6 +122,12 @@ pub struct Script {
|
||||
/// have to add it back when that's built.
|
||||
pub memory_limit_mb: u32,
|
||||
|
||||
/// Three-state lifecycle (§4.3): `false` means deployed-but-inert — the
|
||||
/// script is not invocable (route 404s, trigger doesn't fire) but stays
|
||||
/// as desired state (not pruned). Defaults `true`.
|
||||
#[serde(default = "crate::default_true")]
|
||||
pub enabled: bool,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -50,6 +50,47 @@ export interface App {
|
||||
|
||||
export type AppRole = 'app_admin' | 'editor' | 'viewer';
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
structure_version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GroupDetail extends Group {
|
||||
/** Root → … → this node breadcrumb. */
|
||||
path: Group[];
|
||||
subgroups: Group[];
|
||||
apps: App[];
|
||||
}
|
||||
|
||||
export interface GroupMember {
|
||||
user_id: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
instance_role: InstanceRole;
|
||||
is_active: boolean;
|
||||
role: AppRole;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateGroupInput {
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
/** Parent group slug or id; omit (or null) for a root group. */
|
||||
parent?: string | null;
|
||||
}
|
||||
|
||||
export interface PatchGroupInput {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
|
||||
|
||||
export interface AppDomain {
|
||||
@@ -89,6 +130,8 @@ export interface CreateAppInput {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
force_takeover?: boolean;
|
||||
/** Parent group slug or id; omit for the root group. */
|
||||
group?: string | null;
|
||||
}
|
||||
|
||||
export interface PatchAppInput {
|
||||
@@ -778,6 +821,52 @@ export const api = {
|
||||
)
|
||||
},
|
||||
|
||||
groups: {
|
||||
list: () => adminRequest<Group[]>('/api/v1/admin/groups'),
|
||||
get: (idOrSlug: string) =>
|
||||
adminRequest<GroupDetail>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`),
|
||||
create: (input: CreateGroupInput) =>
|
||||
adminRequest<Group>('/api/v1/admin/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
update: (idOrSlug: string, input: PatchGroupInput) =>
|
||||
adminRequest<Group>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
reparent: (idOrSlug: string, parent: string | null) =>
|
||||
adminRequest<Group>(
|
||||
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/reparent`,
|
||||
{ method: 'POST', body: JSON.stringify({ parent }) }
|
||||
),
|
||||
delete: (idOrSlug: string) =>
|
||||
adminRequest<null>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
members: {
|
||||
list: (idOrSlug: string) =>
|
||||
adminRequest<GroupMember[]>(
|
||||
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members`
|
||||
),
|
||||
grant: (idOrSlug: string, input: GrantAppMemberInput) =>
|
||||
adminRequest<GroupMember>(
|
||||
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members`,
|
||||
{ method: 'POST', body: JSON.stringify(input) }
|
||||
),
|
||||
update: (idOrSlug: string, userId: string, role: AppRole) =>
|
||||
adminRequest<GroupMember>(
|
||||
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
|
||||
{ method: 'PATCH', body: JSON.stringify({ role }) }
|
||||
),
|
||||
remove: (idOrSlug: string, userId: string) =>
|
||||
adminRequest<null>(
|
||||
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
domains: {
|
||||
listForApp: (idOrSlug: string) =>
|
||||
adminRequest<AppDomain[]>(
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<a href={base + '/'} class="brand">PiCloud</a>
|
||||
<nav>
|
||||
<a href={base + '/apps'}>Apps</a>
|
||||
<a href={base + '/groups'}>Groups</a>
|
||||
{#if user && user.instance_role !== 'member'}
|
||||
<a href={base + '/users'}>Users</a>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { api, ApiError, type App } from '$lib/api';
|
||||
import { api, ApiError, type App, type Group } from '$lib/api';
|
||||
import { slugify, SLUG_MAX } from '$lib/slugify';
|
||||
import { canCreateApp } from '$lib/capabilities';
|
||||
import { currentUser } from '$lib/auth';
|
||||
@@ -36,6 +36,17 @@
|
||||
let createSlug = $state('');
|
||||
let createName = $state('');
|
||||
let createDescription = $state('');
|
||||
// Optional parent group for the new app (defaults to root). Loaded
|
||||
// lazily; failure leaves the picker empty (root-only).
|
||||
let createGroup = $state('');
|
||||
let groups = $state<Group[]>([]);
|
||||
async function loadGroups() {
|
||||
try {
|
||||
groups = await api.groups.list();
|
||||
} catch {
|
||||
groups = [];
|
||||
}
|
||||
}
|
||||
// Auto-derive slug from name until the user takes manual control of
|
||||
// the slug field. Clearing the slug input releases the lock so the
|
||||
// auto-derive resumes — matches the GitLab project-create UX.
|
||||
@@ -69,6 +80,7 @@
|
||||
listError = null;
|
||||
try {
|
||||
apps = await api.apps.list();
|
||||
void loadGroups();
|
||||
if (apps && apps.length > 0) {
|
||||
void loadDlCounts(apps);
|
||||
}
|
||||
@@ -84,6 +96,7 @@
|
||||
createSlug = '';
|
||||
createName = '';
|
||||
createDescription = '';
|
||||
createGroup = '';
|
||||
createError = null;
|
||||
createHistoricalConflict = null;
|
||||
slugTouched = false;
|
||||
@@ -99,7 +112,8 @@
|
||||
slug: createSlug.trim(),
|
||||
name: createName.trim(),
|
||||
description: createDescription.trim() || null,
|
||||
force_takeover: forceTakeover || undefined
|
||||
force_takeover: forceTakeover || undefined,
|
||||
group: createGroup.trim() || undefined
|
||||
});
|
||||
showCreate = false;
|
||||
resetCreate();
|
||||
@@ -172,6 +186,15 @@
|
||||
<span>Description</span>
|
||||
<input bind:value={createDescription} placeholder="optional" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Group (optional)</span>
|
||||
<select bind:value={createGroup}>
|
||||
<option value="">— root —</option>
|
||||
{#each groups as g (g.id)}
|
||||
<option value={g.slug}>{g.name} (/{g.slug})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if createHistoricalConflict}
|
||||
<div class="warning">
|
||||
<strong>Slug previously redirected.</strong>
|
||||
|
||||
366
dashboard/src/routes/groups/+page.svelte
Normal file
366
dashboard/src/routes/groups/+page.svelte
Normal file
@@ -0,0 +1,366 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { api, ApiError, type Group } from '$lib/api';
|
||||
import { slugify, SLUG_MAX } from '$lib/slugify';
|
||||
import { canCreateApp } from '$lib/capabilities';
|
||||
import { currentUser } from '$lib/auth';
|
||||
|
||||
const me = $derived($currentUser);
|
||||
// Group creation mirrors app creation's gate — owner/admin only.
|
||||
const canCreate = $derived(canCreateApp(me));
|
||||
|
||||
let groups = $state<Group[] | null>(null);
|
||||
let listError = $state<string | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
// Tree assembly: index children by parent_id so we can render the
|
||||
// flat list as a nested tree. Roots have parent_id === null.
|
||||
interface TreeNode {
|
||||
group: Group;
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
const tree = $derived.by<TreeNode[]>(() => {
|
||||
if (!groups) return [];
|
||||
const byParent = new Map<string | null, Group[]>();
|
||||
for (const g of groups) {
|
||||
const key = g.parent_id;
|
||||
const bucket = byParent.get(key) ?? [];
|
||||
bucket.push(g);
|
||||
byParent.set(key, bucket);
|
||||
}
|
||||
const build = (parentId: string | null): TreeNode[] =>
|
||||
(byParent.get(parentId) ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((group) => ({ group, children: build(group.id) }));
|
||||
return build(null);
|
||||
});
|
||||
|
||||
// Collapse state, keyed by group id. Default: expanded.
|
||||
let collapsed = $state<Record<string, boolean>>({});
|
||||
function toggle(id: string) {
|
||||
collapsed = { ...collapsed, [id]: !collapsed[id] };
|
||||
}
|
||||
|
||||
// Create form
|
||||
let showCreate = $state(false);
|
||||
let createSlug = $state('');
|
||||
let createName = $state('');
|
||||
let createDescription = $state('');
|
||||
let createParent = $state('');
|
||||
let slugTouched = $state(false);
|
||||
let creating = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
|
||||
function onNameInput(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
createName = value;
|
||||
if (!slugTouched) {
|
||||
createSlug = slugify(value);
|
||||
}
|
||||
}
|
||||
|
||||
function onSlugInput(event: Event) {
|
||||
const raw = (event.target as HTMLInputElement).value;
|
||||
const normalized = slugify(raw);
|
||||
createSlug = normalized;
|
||||
if (raw !== normalized) {
|
||||
(event.target as HTMLInputElement).value = normalized;
|
||||
}
|
||||
slugTouched = normalized.length > 0;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
listError = null;
|
||||
try {
|
||||
groups = await api.groups.list();
|
||||
} catch (e) {
|
||||
listError = e instanceof Error ? e.message : String(e);
|
||||
groups = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetCreate() {
|
||||
createSlug = '';
|
||||
createName = '';
|
||||
createDescription = '';
|
||||
createParent = '';
|
||||
createError = null;
|
||||
slugTouched = false;
|
||||
}
|
||||
|
||||
async function submitCreate(event: Event) {
|
||||
event.preventDefault();
|
||||
creating = true;
|
||||
createError = null;
|
||||
try {
|
||||
await api.groups.create({
|
||||
slug: createSlug.trim(),
|
||||
name: createName.trim(),
|
||||
description: createDescription.trim() || null,
|
||||
parent: createParent.trim() || undefined
|
||||
});
|
||||
showCreate = false;
|
||||
resetCreate();
|
||||
await load();
|
||||
} catch (e) {
|
||||
createError =
|
||||
e instanceof ApiError ? e.message : e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<header class="page-header">
|
||||
<h1>Groups</h1>
|
||||
{#if canCreate}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showCreate = !showCreate;
|
||||
if (!showCreate) resetCreate();
|
||||
}}
|
||||
>
|
||||
{showCreate ? 'Cancel' : 'New group'}
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if showCreate && canCreate}
|
||||
<form class="create-form" onsubmit={submitCreate}>
|
||||
<div class="row">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input value={createName} oninput={onNameInput} required placeholder="My Group" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Slug</span>
|
||||
<input
|
||||
value={createSlug}
|
||||
oninput={onSlugInput}
|
||||
required
|
||||
pattern="[a-z0-9][a-z0-9-]*"
|
||||
maxlength={SLUG_MAX}
|
||||
placeholder="my-group"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>Parent group (optional)</span>
|
||||
<select bind:value={createParent}>
|
||||
<option value="">— root —</option>
|
||||
{#each groups ?? [] as g (g.id)}
|
||||
<option value={g.slug}>{g.name} (/{g.slug})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<input bind:value={createDescription} placeholder="optional" />
|
||||
</label>
|
||||
{#if createError}
|
||||
<div class="error">{createError}</div>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={creating}>
|
||||
{creating ? 'Creating…' : 'Create group'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if listError}
|
||||
<div class="error">
|
||||
<strong>Could not load groups.</strong>
|
||||
<p>{listError}</p>
|
||||
<button type="button" onclick={() => void load()}>Retry</button>
|
||||
</div>
|
||||
{:else if groups && groups.length === 0}
|
||||
<p class="muted">No groups yet. Create one above to get started.</p>
|
||||
{:else if groups}
|
||||
<ul class="tree">
|
||||
{#each tree as node (node.group.id)}
|
||||
{@render branch(node, 0)}
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#snippet branch(node: TreeNode, depth: number)}
|
||||
<li>
|
||||
<div class="node" style="padding-left: {depth * 1.25}rem">
|
||||
{#if node.children.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="twisty"
|
||||
aria-label={collapsed[node.group.id] ? 'Expand' : 'Collapse'}
|
||||
onclick={() => toggle(node.group.id)}
|
||||
>
|
||||
{collapsed[node.group.id] ? '▸' : '▾'}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="twisty-spacer"></span>
|
||||
{/if}
|
||||
<a href="{base}/groups/{node.group.slug}">
|
||||
<strong>{node.group.name}</strong>
|
||||
<span class="muted">/{node.group.slug}</span>
|
||||
</a>
|
||||
</div>
|
||||
{#if node.children.length > 0 && !collapsed[node.group.id]}
|
||||
<ul>
|
||||
{#each node.children as child (child.group.id)}
|
||||
{@render branch(child, depth + 1)}
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</li>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #38bdf8;
|
||||
color: #0b1220;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.error {
|
||||
border: 1px solid #b91c1c;
|
||||
background: #450a0a;
|
||||
color: #fecaca;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.create-form .row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.create-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.create-form input {
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tree {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tree ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.node a {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: #1e293b;
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.node a:hover {
|
||||
background: #283549;
|
||||
}
|
||||
|
||||
.twisty {
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
border: none;
|
||||
padding: 0;
|
||||
width: 1.25rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.twisty-spacer {
|
||||
width: 1.25rem;
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
789
dashboard/src/routes/groups/[slug]/+page.svelte
Normal file
789
dashboard/src/routes/groups/[slug]/+page.svelte
Normal file
@@ -0,0 +1,789 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
api,
|
||||
ApiError,
|
||||
type AdminDto,
|
||||
type Group,
|
||||
type GroupDetail,
|
||||
type GroupMember,
|
||||
type AppRole
|
||||
} from '$lib/api';
|
||||
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||
import ActionMenu from '$lib/ActionMenu.svelte';
|
||||
import RoleChip from '$lib/RoleChip.svelte';
|
||||
import { currentUser } from '$lib/auth';
|
||||
import { canCreateApp } from '$lib/capabilities';
|
||||
|
||||
const me = $derived($currentUser);
|
||||
// Mirror app-admin gating: instance owner/admin manage groups, their
|
||||
// members, structure, and deletion. The backend is the ground truth.
|
||||
const canManage = $derived(canCreateApp(me));
|
||||
|
||||
type Tab = 'overview' | 'members' | 'settings';
|
||||
const isValidTab = (s: string | null): s is Tab =>
|
||||
s === 'overview' || s === 'members' || s === 'settings';
|
||||
let activeTab = $derived.by<Tab>(() => {
|
||||
const qp = page.url.searchParams.get('tab');
|
||||
return isValidTab(qp) ? qp : 'overview';
|
||||
});
|
||||
|
||||
let slug = $derived(page.params.slug ?? '');
|
||||
let group = $state<GroupDetail | null>(null);
|
||||
let loadError = $state<string | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
// All groups — used for the reparent + rename forms and to offer a
|
||||
// parent picker that excludes the node itself.
|
||||
let allGroups = $state<Group[]>([]);
|
||||
|
||||
// Settings (rename — name/description only; slug is frozen).
|
||||
let editName = $state('');
|
||||
let editDescription = $state('');
|
||||
let savingSettings = $state(false);
|
||||
let settingsError = $state<string | null>(null);
|
||||
|
||||
// Reparent.
|
||||
let reparentTarget = $state('');
|
||||
let reparenting = $state(false);
|
||||
let reparentError = $state<string | null>(null);
|
||||
|
||||
// Delete.
|
||||
let confirmingDelete = $state(false);
|
||||
let deleting = $state(false);
|
||||
let deleteError = $state<string | null>(null);
|
||||
|
||||
// Members.
|
||||
let members = $state<GroupMember[]>([]);
|
||||
let eligibleUsers = $state<AdminDto[]>([]);
|
||||
let eligibleLoadError = $state<string | null>(null);
|
||||
let addMemberUserId = $state('');
|
||||
let addMemberRole = $state<AppRole>('viewer');
|
||||
let addingMember = $state(false);
|
||||
let addMemberError = $state<string | null>(null);
|
||||
let memberToRemove = $state<GroupMember | null>(null);
|
||||
let removingMember = $state(false);
|
||||
let removeMemberError = $state<string | null>(null);
|
||||
let roleChangeBusy = $state<string | null>(null);
|
||||
let memberActionError = $state<string | null>(null);
|
||||
|
||||
async function loadGroup() {
|
||||
loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const fetched = await api.groups.get(slug);
|
||||
group = fetched;
|
||||
editName = fetched.name;
|
||||
editDescription = fetched.description ?? '';
|
||||
reparentTarget = fetched.parent_id ?? '';
|
||||
const loaders: Promise<unknown>[] = [loadAllGroups()];
|
||||
if (canManage) {
|
||||
loaders.push(loadMembers(slug), loadEligibleUsers());
|
||||
}
|
||||
await Promise.all(loaders);
|
||||
} catch (e) {
|
||||
loadError = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllGroups() {
|
||||
try {
|
||||
allGroups = await api.groups.list();
|
||||
} catch {
|
||||
allGroups = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMembers(idOrSlug: string) {
|
||||
try {
|
||||
members = await api.groups.members.list(idOrSlug);
|
||||
} catch (e) {
|
||||
members = [];
|
||||
memberActionError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEligibleUsers() {
|
||||
eligibleLoadError = null;
|
||||
try {
|
||||
const all = await api.admins.list();
|
||||
eligibleUsers = all.filter((u) => u.is_active && u.instance_role === 'member');
|
||||
} catch (e) {
|
||||
eligibleUsers = [];
|
||||
eligibleLoadError =
|
||||
e instanceof ApiError && e.status === 403
|
||||
? 'Only instance owners/admins can browse the user directory to invite new members.'
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: String(e);
|
||||
}
|
||||
}
|
||||
|
||||
const eligibleAfterFilter = $derived(
|
||||
eligibleUsers.filter((u) => !members.some((m) => m.user_id === u.id))
|
||||
);
|
||||
|
||||
// Reparent picker options: every group except this node (a group
|
||||
// can't parent itself; the backend also rejects descendant cycles
|
||||
// with a 409, surfaced below).
|
||||
const reparentOptions = $derived(allGroups.filter((g) => g.id !== group?.id));
|
||||
|
||||
async function saveSettings(event: Event) {
|
||||
event.preventDefault();
|
||||
if (!group) return;
|
||||
savingSettings = true;
|
||||
settingsError = null;
|
||||
try {
|
||||
const updated = await api.groups.update(group.id, {
|
||||
name: editName.trim() !== group.name ? editName.trim() : undefined,
|
||||
description:
|
||||
editDescription !== (group.description ?? '')
|
||||
? editDescription || null
|
||||
: undefined
|
||||
});
|
||||
group = { ...group, name: updated.name, description: updated.description };
|
||||
} catch (e) {
|
||||
settingsError = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
savingSettings = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReparent(event: Event) {
|
||||
event.preventDefault();
|
||||
if (!group) return;
|
||||
reparenting = true;
|
||||
reparentError = null;
|
||||
try {
|
||||
await api.groups.reparent(group.id, reparentTarget.trim() || null);
|
||||
await loadGroup();
|
||||
} catch (e) {
|
||||
reparentError =
|
||||
e instanceof ApiError && e.status === 409
|
||||
? e.message
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: String(e);
|
||||
} finally {
|
||||
reparenting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function askDelete() {
|
||||
deleteError = null;
|
||||
confirmingDelete = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!group) return;
|
||||
deleting = true;
|
||||
deleteError = null;
|
||||
try {
|
||||
await api.groups.delete(group.id);
|
||||
await goto(`${base}/groups`);
|
||||
} catch (e) {
|
||||
// 409 RESTRICT: the group still has subgroups or apps. Surface
|
||||
// the server's message cleanly rather than a bare status code.
|
||||
deleteError = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAddMember(event: Event) {
|
||||
event.preventDefault();
|
||||
if (!group || !addMemberUserId) return;
|
||||
addingMember = true;
|
||||
addMemberError = null;
|
||||
try {
|
||||
await api.groups.members.grant(group.id, {
|
||||
user_id: addMemberUserId,
|
||||
role: addMemberRole
|
||||
});
|
||||
addMemberUserId = '';
|
||||
addMemberRole = 'viewer';
|
||||
await loadMembers(group.id);
|
||||
} catch (e) {
|
||||
addMemberError = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
addingMember = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeMemberRole(member: GroupMember, role: AppRole) {
|
||||
if (!group || member.role === role) return;
|
||||
roleChangeBusy = member.user_id;
|
||||
memberActionError = null;
|
||||
try {
|
||||
await api.groups.members.update(group.id, member.user_id, role);
|
||||
await loadMembers(group.id);
|
||||
} catch (e) {
|
||||
memberActionError = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
roleChangeBusy = null;
|
||||
}
|
||||
}
|
||||
|
||||
function askRemoveMember(member: GroupMember) {
|
||||
removeMemberError = null;
|
||||
memberToRemove = member;
|
||||
}
|
||||
|
||||
async function confirmRemoveMember() {
|
||||
if (!group || !memberToRemove) return;
|
||||
removingMember = true;
|
||||
removeMemberError = null;
|
||||
try {
|
||||
await api.groups.members.remove(group.id, memberToRemove.user_id);
|
||||
memberToRemove = null;
|
||||
await loadMembers(group.id);
|
||||
} catch (e) {
|
||||
removeMemberError = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
removingMember = false;
|
||||
}
|
||||
}
|
||||
|
||||
function shortDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void slug;
|
||||
void loadGroup();
|
||||
});
|
||||
|
||||
// A viewer following a stale link to a manage-only tab gets bounced
|
||||
// to the overview. The backend still 403s the underlying calls.
|
||||
$effect(() => {
|
||||
if (!canManage && (activeTab === 'settings' || activeTab === 'members')) {
|
||||
void goto(`${base}/groups/${slug}?tab=overview`, { replaceState: true });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<header class="page-head">
|
||||
<div class="breadcrumb">
|
||||
<a href="{base}/groups">Groups</a>
|
||||
{#if group}
|
||||
{#each group.path as p (p.id)}
|
||||
<span aria-hidden="true">/</span>
|
||||
{#if p.id === group.id}
|
||||
<code>{p.slug}</code>
|
||||
{:else}
|
||||
<a href="{base}/groups/{p.slug}">{p.name}</a>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<span aria-hidden="true">/</span>
|
||||
<code>{slug}</code>
|
||||
{/if}
|
||||
</div>
|
||||
{#if group}
|
||||
<h1>{group.name}</h1>
|
||||
{#if group.description}<p class="muted">{group.description}</p>{/if}
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if group}
|
||||
<nav class="tabbar">
|
||||
<a href="{base}/groups/{slug}?tab=overview" class:active={activeTab === 'overview'}>Overview</a>
|
||||
{#if canManage}
|
||||
<a href="{base}/groups/{slug}?tab=members" class:active={activeTab === 'members'}>Members</a>
|
||||
<a href="{base}/groups/{slug}?tab=settings" class:active={activeTab === 'settings'}>Settings</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
{#if activeTab === 'overview'}
|
||||
<section>
|
||||
<h2>Subgroups</h2>
|
||||
{#if group.subgroups.length === 0}
|
||||
<p class="muted">No subgroups.</p>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each group.subgroups as sub (sub.id)}
|
||||
<li>
|
||||
<a href="{base}/groups/{sub.slug}">
|
||||
<div class="primary">
|
||||
<strong>{sub.name}</strong>
|
||||
<span class="muted">/{sub.slug}</span>
|
||||
</div>
|
||||
<div class="secondary muted">{sub.description ?? '—'}</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<h2>Apps</h2>
|
||||
{#if group.apps.length === 0}
|
||||
<p class="muted">No apps in this group.</p>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each group.apps as app (app.id)}
|
||||
<li>
|
||||
<a href="{base}/apps/{app.slug}">
|
||||
<div class="primary">
|
||||
<strong>{app.name}</strong>
|
||||
<span class="muted">/{app.slug}</span>
|
||||
</div>
|
||||
<div class="secondary muted">{app.description ?? '—'}</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if activeTab === 'members' && canManage}
|
||||
<section>
|
||||
<h2>Members</h2>
|
||||
<p class="muted">
|
||||
Users with explicit access to this group. Instance owners and admins
|
||||
already have implicit access — they are not listed here. Use the Users
|
||||
page to invite a <code>member</code> first, then grant them group access
|
||||
below.
|
||||
</p>
|
||||
|
||||
<form class="create-form" onsubmit={submitAddMember}>
|
||||
<div class="row">
|
||||
<label class="grow">
|
||||
<span>User</span>
|
||||
<select
|
||||
bind:value={addMemberUserId}
|
||||
disabled={!!eligibleLoadError || eligibleAfterFilter.length === 0}
|
||||
required
|
||||
>
|
||||
<option value="" disabled>Pick a member to invite…</option>
|
||||
{#each eligibleAfterFilter as u (u.id)}
|
||||
<option value={u.id}>{u.username}{u.email ? ` (${u.email})` : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Role</span>
|
||||
<select bind:value={addMemberRole} disabled={!!eligibleLoadError}>
|
||||
<option value="viewer">viewer</option>
|
||||
<option value="editor">editor</option>
|
||||
<option value="app_admin">app admin</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{#if eligibleLoadError}
|
||||
<p class="muted">{eligibleLoadError}</p>
|
||||
{:else if eligibleAfterFilter.length === 0}
|
||||
<p class="muted">
|
||||
No eligible users to invite. Create a <code>member</code> on the Users
|
||||
page first.
|
||||
</p>
|
||||
{/if}
|
||||
{#if addMemberError}
|
||||
<div class="error">{addMemberError}</div>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addingMember || !addMemberUserId || !!eligibleLoadError}
|
||||
>
|
||||
{addingMember ? 'Adding…' : 'Add member'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if memberActionError}
|
||||
<div class="error">{memberActionError}</div>
|
||||
{/if}
|
||||
|
||||
{#if members.length === 0}
|
||||
<p class="muted">No explicit members yet.</p>
|
||||
{:else}
|
||||
<div class="table">
|
||||
<div class="row head-row">
|
||||
<div>User</div>
|
||||
<div>Instance</div>
|
||||
<div>Group role</div>
|
||||
<div>Joined</div>
|
||||
<div class="actions-col"></div>
|
||||
</div>
|
||||
{#each members as m (m.user_id)}
|
||||
<div class="row member-row" class:inactive={!m.is_active}>
|
||||
<div>
|
||||
<strong>{m.username}</strong>
|
||||
{#if m.email}<span class="muted">{m.email}</span>{/if}
|
||||
{#if !m.is_active}<span class="muted">(inactive)</span>{/if}
|
||||
</div>
|
||||
<div><RoleChip role={m.instance_role} size="sm" /></div>
|
||||
<div><RoleChip appRole={m.role} size="sm" /></div>
|
||||
<div>{shortDate(m.created_at)}</div>
|
||||
<div class="actions-col">
|
||||
<ActionMenu
|
||||
label="Member actions for {m.username}"
|
||||
items={[
|
||||
{
|
||||
label: 'Make app admin',
|
||||
disabled: m.role === 'app_admin' || roleChangeBusy === m.user_id,
|
||||
onClick: () => changeMemberRole(m, 'app_admin')
|
||||
},
|
||||
{
|
||||
label: 'Make editor',
|
||||
disabled: m.role === 'editor' || roleChangeBusy === m.user_id,
|
||||
onClick: () => changeMemberRole(m, 'editor')
|
||||
},
|
||||
{
|
||||
label: 'Make viewer',
|
||||
disabled: m.role === 'viewer' || roleChangeBusy === m.user_id,
|
||||
onClick: () => changeMemberRole(m, 'viewer')
|
||||
},
|
||||
{
|
||||
label: 'Remove from group',
|
||||
danger: true,
|
||||
onClick: () => askRemoveMember(m)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if activeTab === 'settings' && canManage}
|
||||
<section>
|
||||
<h2>Settings</h2>
|
||||
<form class="create-form" onsubmit={saveSettings}>
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input bind:value={editName} required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<input bind:value={editDescription} />
|
||||
</label>
|
||||
<p class="muted small">
|
||||
The slug <code>/{group.slug}</code> is frozen and cannot be changed.
|
||||
</p>
|
||||
{#if settingsError}
|
||||
<div class="error">{settingsError}</div>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={savingSettings}>
|
||||
{savingSettings ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h2>Move group</h2>
|
||||
<form class="create-form" onsubmit={submitReparent}>
|
||||
<label>
|
||||
<span>Parent group</span>
|
||||
<select bind:value={reparentTarget}>
|
||||
<option value="">— root —</option>
|
||||
{#each reparentOptions as g (g.id)}
|
||||
<option value={g.id}>{g.name} (/{g.slug})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if reparentError}
|
||||
<div class="error">{reparentError}</div>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={reparenting}>
|
||||
{reparenting ? 'Moving…' : 'Move group'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="danger-zone">
|
||||
<h3>Delete group</h3>
|
||||
<p class="muted">
|
||||
Removes this group. The group must be empty first — it can't have any
|
||||
subgroups or apps.
|
||||
</p>
|
||||
<button type="button" class="danger" onclick={askDelete}>Delete group</button>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if confirmingDelete}
|
||||
<ConfirmModal
|
||||
title="Delete group “{group.name}”"
|
||||
variant="danger"
|
||||
confirmLabel="Delete group"
|
||||
busyLabel="Deleting…"
|
||||
busy={deleting}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => (confirmingDelete = false)}
|
||||
>
|
||||
<p>
|
||||
This permanently removes the group <strong>{group.name}</strong>. The
|
||||
group must be empty — if it still has subgroups or apps, the delete is
|
||||
rejected and the reason appears below.
|
||||
</p>
|
||||
{#if deleteError}
|
||||
<p class="modal-error">{deleteError}</p>
|
||||
{/if}
|
||||
</ConfirmModal>
|
||||
{/if}
|
||||
|
||||
{#if memberToRemove}
|
||||
<ConfirmModal
|
||||
title="Remove {memberToRemove.username} from {group.name}"
|
||||
variant="danger"
|
||||
confirmLabel="Remove member"
|
||||
busyLabel="Removing…"
|
||||
busy={removingMember}
|
||||
onConfirm={confirmRemoveMember}
|
||||
onCancel={() => (memberToRemove = null)}
|
||||
>
|
||||
<p>
|
||||
<strong>{memberToRemove.username}</strong> will lose access to this
|
||||
group. Their other memberships and account are untouched.
|
||||
</p>
|
||||
{#if removeMemberError}
|
||||
<p class="modal-error">{removeMemberError}</p>
|
||||
{/if}
|
||||
</ConfirmModal>
|
||||
{/if}
|
||||
{:else if loading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if loadError}
|
||||
<p class="error">{loadError}</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page-head {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: baseline;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.breadcrumb a {
|
||||
color: var(--color-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
.breadcrumb code {
|
||||
background: var(--bg-elevated);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
h1 {
|
||||
margin: 0.25rem 0 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.125rem;
|
||||
margin: 1.5rem 0 1rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.tabbar {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tabbar a {
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tabbar a:hover {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.tabbar a.active {
|
||||
color: #e2e8f0;
|
||||
border-bottom-color: #38bdf8;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #38bdf8;
|
||||
color: #0b1220;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: #7f1d1d;
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
border: 1px solid #b91c1c;
|
||||
background: #450a0a;
|
||||
color: #fecaca;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.modal-error {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.create-form .row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.create-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.create-form .row > label.grow {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.create-form input,
|
||||
.create-form select {
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.list a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background: #1e293b;
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.list a:hover {
|
||||
background: #283549;
|
||||
}
|
||||
|
||||
.primary {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid #7f1d1d;
|
||||
border-radius: 0.5rem;
|
||||
background: #1e0a0a;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.table .row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr 3rem;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background: #1e293b;
|
||||
border-radius: 0.375rem;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table .head-row {
|
||||
background: transparent;
|
||||
padding: 0.25rem 1rem;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.table .member-row.inactive {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.table .member-row strong {
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
.table .member-row .muted {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.table .actions-col {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -296,12 +296,23 @@ CLI builds bundle (manifests + script sources) for the subtree
|
||||
and it is **honored at match/schedule time** (`trigger_repo.rs`, `cron_scheduler.rs` — verified).
|
||||
New work is `enabled` on **scripts** and **routes** only, plus runtime honoring in the matcher /
|
||||
invoker.
|
||||
- **Outbox fire-time gap (verified):** the dispatcher does **not** re-check `enabled` on an
|
||||
already-enqueued outbox row (`dispatcher.rs:699`), so disabling a trigger stops *new* matches while
|
||||
a pending item still fires. **Fix:** add an `enabled` re-check (trigger *and* script) at fire time
|
||||
in `resolve_trigger`, so a pending outbox row for a now-disabled trigger/script is dropped when it
|
||||
comes up — closing the gap cheaply, with no cache or kill-switch involvement. This is the home for
|
||||
the §5.1 security-disable guarantee on the trigger path.
|
||||
- **Fire-time re-check (shipped, test-backed):** the dispatcher re-checks `enabled` at fire time on
|
||||
every async path, so a pending event for a now-disabled trigger/script is dropped (not executed)
|
||||
when it comes up — the §5.1 security-disable guarantee on the trigger path. **Outbox arm:**
|
||||
`resolve_trigger` sets `active = trigger.enabled && script.enabled` and the async-HTTP/invoke
|
||||
builders set `active = script.enabled`; `dispatch_one` drops the row via a single unified
|
||||
`if !resolved.active` gate. **Queue arm:** `dispatch_one_queue` runs a separate path (it claims from
|
||||
`queue_messages`, not the outbox), so in addition to `list_active_queue_consumers` filtering
|
||||
`s.enabled`/`t.enabled` at list time, it re-checks the **freshly-read** `script.enabled` before
|
||||
executing and **releases the claim (`nack`)** when disabled — closing the per-tick TOCTOU window
|
||||
(a script disabled after the list snapshot but before its in-flight message runs). Both arms are
|
||||
regression-locked: `dispatcher::tests::outbox_enabled_gate::disabled_http_outbox_row_is_dropped_without_executing`
|
||||
covers the unified `!resolved.active` outbox gate, and
|
||||
`dispatcher::tests::queue_enabled_gate::disabled_queue_consumer_releases_claim_without_executing`
|
||||
covers the queue arm. **Same-app backstop:** every async build path (`resolve_trigger`,
|
||||
`build_http_request`, `build_invoke_request`, `dispatch_one_queue`) rejects a row whose script's
|
||||
`app_id` ≠ the row's `app_id`, so a hand-edited/restored row can't run one app's script under
|
||||
another's `SdkCallCx` — the cross-app isolation boundary.
|
||||
- **Provenance caveat (accepted):** a single boolean carries no "manual vs manifest" provenance, so a
|
||||
disabled entity looks the same however it got there — which is *why* the §4.2 conflict bit on the
|
||||
`enabled`/secrets subset exists, to stop the next apply silently reverting an operational disable.
|
||||
@@ -813,6 +824,23 @@ Resolved items now live inline next to their topic. What genuinely remains:
|
||||
|
||||
## 11. Suggested phasing
|
||||
|
||||
> **Status (Phase 1): ✅ shipped.** `init`, `pull`, `config --effective`, manifest parse/validate,
|
||||
> `plan` + `apply` (single-transaction desired-state write, post-commit route refresh, domains/files
|
||||
> outside the tx), `prune`, secrets push, `.picloud/` link state, env-scoped overlays
|
||||
> (`picloud.<env>.toml` base+overlay), the three-state `enabled` lifecycle on scripts/routes (runtime
|
||||
> 404 + dispatcher fire-time re-check), the trigger `name` column + backfill, a coarse per-app apply
|
||||
> lock, and in-flight-finish-no-kill all landed.
|
||||
>
|
||||
> The **bound-plan staleness check** is the **content-fingerprint** form (a `state_token` hash of the
|
||||
> live state; apply 409s if it moved) rather than a persisted plan-artifact + serial — it delivers the
|
||||
> §4.2 guarantee without a migration or interactive-write-path changes, and the token covers
|
||||
> `enabled`/secret names so the **`enabled`/secrets conflict** is enforced on the plan→apply path.
|
||||
>
|
||||
> Deferred, with rationale: the **tree-structure version counter** is a no-op seam until groups exist
|
||||
> (Phase 2); **`vars`** + multi-level/`--explain` resolution and the richer per-env overlay are Phase 3;
|
||||
> trigger **upsert-by-name** (in-place Update; the diff still Create/Deletes by semantic identity) and
|
||||
> the **dashboard enabled toggle** are tracked follow-ups beyond the §11 bullets.
|
||||
|
||||
1. **Declarative project tool, single-app (no groups yet).** `init`, `pull`/`config --effective`,
|
||||
manifest parse/validate, `plan` (bound artifact), `apply` (**atomic desired-state write — requires
|
||||
the manager-core post-commit-refresh restructuring of §4.2, domains/files excluded from the
|
||||
@@ -825,6 +853,28 @@ Resolved items now live inline next to their topic. What genuinely remains:
|
||||
RESTRICT**, reparent/rename with the **ancestor-walk cycle guard** + **slug-freeze** +
|
||||
**tree-structure version**, §5.6), inherited membership, hierarchy-aware `can`, UI grouping, the §9
|
||||
backfill. No shared resources yet — cheap, no data-plane schema change.
|
||||
|
||||
> **Status (Phase 2): ✅ shipped.** Migration `0047_groups.sql` adds `groups` (self-FK
|
||||
> `parent_id` ON DELETE RESTRICT, instance-global frozen `slug`, `structure_version`, inert
|
||||
> `owner_project` §7 seam) + `group_members` (same `app_admin|editor|viewer` literals as
|
||||
> `app_members`) + `apps.group_id` (NOT NULL, RESTRICT) with the §9 single-root backfill. The
|
||||
> tree lives in `group_repo` (reparent: ancestor-walk cycle guard under a coarse instance-wide
|
||||
> structural advisory lock, slug-freeze, `structure_version` bump; delete=RESTRICT) and
|
||||
> `group_members_repo`. **Hierarchy-aware RBAC** (§5.3): `AuthzRepo::effective_app_role` /
|
||||
> `effective_group_role` resolve the highest role across the app's own membership + every ancestor
|
||||
> group via one depth-bounded recursive CTE; `authz::can`'s Member path folds inherited group
|
||||
> roles, so a `group_admin` on any ancestor is implicitly app_admin beneath it — resolved **live**
|
||||
> per request (revocation is immediate). New caps `InstanceCreateGroup` / `Group{Read,Write,Admin}`
|
||||
> (no `app_id` ⇒ bound API keys can't manage groups). Surfaced via `groups_api`, `pic groups …`,
|
||||
> and a dashboard group tree + detail/members. `structure_version` is bumped but not yet a consumer
|
||||
> beyond the reparent path (CLI structural-drift detection is Phase 5).
|
||||
>
|
||||
> Deferred to Phase 3+ (unchanged): group-*owned* scripts/vars/secret-refs, the materialized
|
||||
> effective-view resolver, masked group secrets + the group-secret AAD scheme, module/import
|
||||
> resolution under inheritance, `config --effective --explain`, and personal-namespace root groups
|
||||
> (the backfill seeds a single instance root). The §10 **inherited-membership revocation-lag** risk
|
||||
> does not bite in Phase 2 because resolution is live (no inherited-role cache); it returns only
|
||||
> when Phase 3's materialized views land.
|
||||
3. **Group-inherited config** (vars, secret-refs, env-scoped). The net-new `vars`/`secret-refs`
|
||||
tables + polymorphic owner; the group-secret AAD scheme (§5.3 caveat); masked group secrets; the
|
||||
effective-view resolver + materialization + invalidation; **`config --effective --explain`** (hard
|
||||
|
||||
Reference in New Issue
Block a user