feat(group): 1st implementation of Groups

this implements first version (manageable only by admin right now)

    routes:

        GET /api/groups
        List subject groups (paginated). Admin-only.

        POST /api/groups
        Create a new ReBAC subject group. Admin-only. The name must match the RFC 5321 local-part shape and be globally unique (case-insensitive).

        GET /api/groups/search
        Search non-virtual groups by name substring. Authenticated only (no admin role required) — backs the share-dialog recipient autocomplete.

        GET /api/groups/{id}
        Fetch a single group's details. Admin-only.

        DELETE /api/groups/{id}
        Delete a group. Cascades to `subject_group_members` (FK) and to `access_grants` rows referencing this group as a subject. Admin-only.

        PATCH /api/groups/{id}
        Update a group's metadata. Admin-only. v1 only persists name renames.

        GET /api/groups/{id}/effective-members
        List every user transitively reached through this group (members of members of members, etc.). Used by admin / audit tooling. Admin-only.

        GET /api/groups/{id}/members
        List the *direct* members of a group (one level only). Admin-only.

        POST /api/groups/{id}/members
        Add a member to a group. Exactly one of `user_id` / `group_id` must be provided. Adding a group-member runs a write-time cycle check and a nesting-depth check (max 8). Admin-only.

        DELETE /api/groups/{id}/members/group/{gid}
        Remove a nested group-member from a group. Admin-only.

        DELETE /api/groups/{id}/members/user/{uid}
        Remove a user-member from a group. Admin-only.

fix hurl

groups

round

groups
This commit is contained in:
Edouard Vanbelle
2026-05-30 23:35:47 +02:00
parent 41356b6490
commit 09985f8a95
54 changed files with 6421 additions and 145 deletions
+92
View File
@@ -0,0 +1,92 @@
//! Admin role guard — shared across handlers that gate on `claims.role == "admin"`.
//!
//! Extracted from `admin_handler.rs::admin_guard` so the subject-group
//! handler (and any future admin-only surface) can reuse the same code path
//! without duplication.
//!
//! Returns `(user_id, role)` on success so callers have the caller's UUID
//! for audit / ownership purposes.
use axum::http::{HeaderMap, StatusCode, header};
use uuid::Uuid;
use crate::application::ports::auth_ports::TokenServicePort;
use crate::common::di::AppState;
use crate::interfaces::api::cookie_auth::{ACCESS_COOKIE, extract_cookie_value};
use crate::interfaces::errors::AppError;
/// Validate the request's JWT (from the `Authorization: Bearer …` header
/// or the access-token cookie) and require `claims.role == "admin"`.
///
/// On success returns `(user_id, role)`; on failure returns:
/// - 401 if no token / invalid token,
/// - 403 if the token is valid but the role is not `admin`,
/// - 500 if the auth service is not configured.
pub async fn require_admin(
state: &AppState,
headers: &HeaderMap,
) -> Result<(Uuid, String), AppError> {
let auth = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
let token = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer ").map(|s| s.to_string()))
.or_else(|| extract_cookie_value(headers, ACCESS_COOKIE))
.ok_or_else(|| AppError::unauthorized("Authorization token required"))?;
let claims = auth
.token_service
.validate_token(&token)
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
if claims.role != "admin" {
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Admin access required",
"Forbidden",
));
}
Ok((
Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?,
claims.role,
))
}
/// Validate the request's JWT (any role) and return `(user_id, role)`.
///
/// Like `require_admin` but does not enforce the admin role — useful for
/// share-dialog autocomplete and similar surfaces that need a logged-in
/// caller but don't care about their role.
pub async fn require_authenticated(
state: &AppState,
headers: &HeaderMap,
) -> Result<(Uuid, String), AppError> {
let auth = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
let token = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer ").map(|s| s.to_string()))
.or_else(|| extract_cookie_value(headers, ACCESS_COOKIE))
.ok_or_else(|| AppError::unauthorized("Authorization token required"))?;
let claims = auth
.token_service
.validate_token(&token)
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
Ok((
Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?,
claims.role,
))
}
+1
View File
@@ -1,3 +1,4 @@
pub mod admin;
pub mod auth;
pub mod csrf;
pub mod rate_limit;