Files
PiCloud/crates/manager-core/src/groups_api.rs
MechaCat02 b33c87e5c4 feat(cli): [project] manifest block, --takeover, groups ls owner column
Makes §7 ownership usable end to end from the CLI.

- manifest: a `[project]` block (slug + optional name), independent of the
  [app]/[group] XOR; threaded onto every apply from the repo. --dir reads it
  from the tree ROOT's picloud.toml (a [project] elsewhere is ignored with a
  note).
- client: apply_node/apply_tree carry project + takeover; the 409 branch now
  surfaces the server message verbatim (covers StateMoved AND OwnershipConflict,
  both self-contained). New groups_list_with_owner captures the owner slug.
- pic apply --takeover flag; pic groups ls gains an `owner` column (the owning
  project's slug, or — when unclaimed).
- server: /api/v1/admin/groups now returns each group flattened with its owner
  slug (via list_with_owner) — the shared Group deserialize ignores the extra
  field, so pic groups tree / the dashboard are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:46:09 +02:00

619 lines
20 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.
if let Err(e) = crate::materialize::rematerialize_stateful_templates(&s.pool).await {
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()))
}
/// 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 163 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()
}
}