Files
PiCloud/crates/manager-core/src/groups_api.rs
MechaCat02 5e1bda1f32 fix(core): cron double-fire, materialize warnings, group quota fast path, SSE channel cap
Remediate the MEDIUM backend correctness/perf findings from the 2026-07-11 audit.

B1 — a group cron template toggled disabled→enabled no longer re-fires every
descendant's cron. Materialization now KEEPS a disabled cron template's copies
(syncing `enabled` in place) instead of delete+recreate, preserving
`last_fired_at`; the scheduler already skips disabled copies, so keeping them is
inert. Queue/email arms still delete-on-disable (freeing the one-consumer slot).

B2 — `rematerialize_stateful_templates` warnings are now logged at the
app-create/delete and group-reparent chokepoints (were silently dropped); only
the `Err` arm was handled before.

B3 — the group KV/docs byte-quota check skips the O(rows) `SUM(octet_length(..))`
scan when a cheap upper bound (`rows_after * max_value_bytes`) is already under
the cap, so the full aggregate only runs near-cap. Every row is validated
≤ max_value_bytes, so the bound is sound (never lets an over-quota write through).

B6 — the in-process SSE broadcaster caps live channels per map
(`PICLOUD_REALTIME_MAX_CHANNELS`, default 100k); a subscribe that would open a
NEW channel past the cap is refused with 503 + `Retry-After: 1` rather than
growing the (app,topic)/(group,topic) maps unboundedly. Check+insert is atomic
under the map mutex (no TOCTOU); an existing-channel subscribe is exempt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:47:47 +02:00

633 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `/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 picloud_orchestrator_core::routing::RouteTable;
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};
use crate::route_repo::RouteRepository;
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>,
/// §11 tail full-live invalidation: reparenting a group moves a whole
/// subtree, changing which ancestor-group route TEMPLATES its descendant
/// apps inherit — so the route snapshot is rebuilt after a reparent.
pub routes: Arc<dyn RouteRepository>,
pub route_table: Arc<RouteTable>,
/// §4.5 M5: reparenting moves a subtree, changing which ancestor-group
/// stateful templates its apps inherit — their materialized cron/queue
/// copies are reconciled after a reparent.
pub pool: sqlx::PgPool,
}
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.
/// §7: a group plus its owning project's slug (`None` = unclaimed / UI-owned).
/// The group fields are flattened, so existing consumers (dashboard, `pic
/// groups tree`) keep working; `owner` is a new, optional field.
#[derive(Serialize)]
struct GroupListItem {
#[serde(flatten)]
group: Group,
owner: Option<String>,
}
async fn list_groups(
State(s): State<GroupsState>,
Extension(_principal): Extension<Principal>,
) -> Result<Json<Vec<GroupListItem>>, GroupsApiError> {
let items = s
.groups
.list_with_owner()
.await?
.into_iter()
.map(|(group, owner)| GroupListItem { group, owner })
.collect();
Ok(Json(items))
}
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?;
// §11 tail: the moved subtree now inherits a different set of ancestor-group
// route TEMPLATES — rebuild the snapshot (best-effort; self-heals on the
// next route write or restart if it fails).
if let Err(e) = crate::route_admin::rebuild_route_table(s.routes.as_ref(), &s.route_table).await
{
tracing::warn!(error = %e, "groups: route table refresh after reparent failed; it will self-heal");
}
// §4.5 M5: the moved subtree inherits a different set of ancestor-group
// stateful templates — reconcile its materialized copies.
match crate::materialize::rematerialize_stateful_templates(&s.pool).await {
Ok(warnings) => {
for w in warnings {
tracing::warn!(warning = %w, "groups: stateful-template materialization warning after reparent");
}
}
Err(e) => {
tracing::warn!(error = %e, "groups: stateful-template materialization after reparent failed; it will self-heal");
}
}
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()))
}
/// The shared slug FORMAT rule (`^[a-z0-9][a-z0-9-]{0,62}$`), returning the
/// human reason on failure so each caller can wrap it in its own error type.
/// Shared by group slugs and §7 project slugs; the reserved-word check is
/// layered on separately by callers that route slugs (groups), not by projects
/// (never routed). See [`crate::apply_service`]'s `validate_project_slug`.
pub(crate) fn validate_slug_format(slug: &str) -> Result<(), String> {
if slug.is_empty() || slug.len() > SLUG_MAX {
return Err("must be 163 characters".to_string());
}
let first = slug.chars().next().expect("non-empty checked above");
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err("must start with a lowercase letter or digit".to_string());
}
if !slug
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err("may contain only lowercase letters, digits, and hyphens".to_string());
}
Ok(())
}
/// 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}"));
validate_slug_format(slug).map_err(|reason| invalid(&reason))?;
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()
}
}