//! `/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, pub group_members: Arc, pub apps: Arc, pub users: Arc, pub authz: Arc, } 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, pub subgroups: Vec, pub apps: Vec, } #[derive(Debug, Deserialize)] pub struct CreateGroupRequest { pub slug: String, pub name: String, pub description: Option, /// Parent group (slug or id). Omitted ⇒ a root-level group /// (owner/admin only). #[serde(default)] pub parent: Option, } #[derive(Debug, Deserialize)] pub struct PatchGroupRequest { pub name: Option, pub description: Option, } #[derive(Debug, Deserialize)] pub struct ReparentRequest { /// New parent (slug or id). Omitted/null ⇒ move to root. #[serde(default)] pub parent: Option, } #[derive(Debug, Serialize)] pub struct GroupMemberDto { pub user_id: AdminUserId, pub username: String, pub email: Option, pub instance_role: InstanceRole, pub is_active: bool, pub role: AppRole, pub created_at: DateTime, } impl From 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, Extension(_principal): Extension, ) -> Result>, GroupsApiError> { Ok(Json(s.groups.list().await?)) } async fn create_group( State(s): State, Extension(principal): Extension, Json(input): Json, ) -> Result<(StatusCode, Json), 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, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result, 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, Extension(principal): Extension, Path(id_or_slug): Path, Json(input): Json, ) -> Result, 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, Extension(principal): Extension, Path(id_or_slug): Path, Json(input): Json, ) -> Result, 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, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result { 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, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result>, 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, Extension(principal): Extension, Path(id_or_slug): Path, Json(input): Json, ) -> Result<(StatusCode, Json), 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, Extension(principal): Extension, Path((id_or_slug, user_id)): Path<(String, Uuid)>, Json(input): Json, ) -> Result, 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, Extension(principal): Extension, Path((id_or_slug, user_id)): Path<(String, Uuid)>, ) -> Result { 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 { let found = if let Ok(uuid) = ident.parse::() { 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 for GroupsApiError { fn from(d: AuthzDenied) -> Self { match d { AuthzDenied::Denied => Self::Forbidden, AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), } } } impl From 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() } }