The runtime half. Group route TEMPLATES now expand into every descendant app's in-memory match slice, live, with no per-app materialization. - `compile_effective_routes` consumes the `list_effective` expansion and applies NEAREST-OWNER-WINS shadowing: for one app, identical binding tuples (method+host+path) owned at multiple chain levels collapse to the nearest (an app's own route shadows an ancestor-group template); a nearer group beats a farther one. Non-identical bindings coexist and the existing matcher precedence resolves each request. `compile_route` now takes the effective app_id, so the matcher + dispatch are unchanged. - `rebuild_route_table` is the single refresh chokepoint (list_effective → compile_effective_routes → replace_all). Route CRUD, the apply reconcile, and startup all route through it; the apply guards drop the "a group node touches no routes" assumption (a group apply may now create templates). - FULL-LIVE INVALIDATION: app create/delete (apps_api) and group reparent (groups_api) rebuild the snapshot, so inherited routes take effect the instant the tree changes — matching the live model of triggers/vars. Best-effort, mirroring apply: a failed rebuild self-heals on the next write or restart, never failing the committed mutation. The isolation boundary is the chain expansion: a template lands only in the slices of apps whose ancestor chain contains the owning group. Pinned by a deterministic live-DB integration test (descendant inherits, sibling subtree does not, app shadows by identical binding, non-identical coexists). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
593 lines
19 KiB
Rust
593 lines
19 KiB
Rust
//! `/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>,
|
||
}
|
||
|
||
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?;
|
||
// §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");
|
||
}
|
||
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()
|
||
}
|
||
}
|