feat(groups): tree repos, hierarchy-aware RBAC, admin API
Server-side foundation for Phase-2 groups (no group-owned resources yet):
Shared types:
- GroupId, Group; App gains group_id; AppRole::{precedence,max} for
folding the highest effective role across the membership chain.
Repos:
- group_repo: tree CRUD with reparent (ancestor-walk cycle guard under a
coarse instance-wide structural advisory lock; slug frozen; bumps
structure_version) and delete=RESTRICT (refuses non-empty groups).
- group_members_repo: per-(user, group) role grants, mirroring app_members.
Hierarchy-aware authz (§5.3):
- AuthzRepo gains effective_app_role / effective_group_role (default to
direct membership / none, so the ~18 existing test stubs are untouched);
the Postgres impl resolves each via one depth-bounded recursive CTE that
MAXes the app's own row with every ancestor group_members row.
- can(): the Member path now folds inherited group roles, so a group_admin
on any ancestor is implicitly app_admin beneath it. New Capability
variants InstanceCreateGroup / Group{Read,Write,Admin}; group caps carry
no app_id (bound API keys can't manage groups). 8 new unit tests.
Admin API:
- groups_api: group CRUD + reparent (admin at both source and destination
parent, §5.6) + per-group members, all capability-gated.
- apps: POST /apps takes an optional parent group (default root); app
responses carry group_id; my_role now reflects the effective role.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user