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
+59
View File
@@ -194,6 +194,52 @@ impl Error for CalendarEventError {}
/// Type alias for CalendarEvent entity operation results
pub type CalendarEventResult<T> = Result<T, CalendarEventError>;
// ============================================================================
// SUBJECT GROUP ERRORS
// ============================================================================
/// Errors that can occur during SubjectGroup entity operations.
///
/// Subject groups are ReBAC authorization principals: root-owned, named with
/// RFC 5321 local-part shape (so they may later be addressed as email locals),
/// and able to nest (a group can contain other groups, with cycle detection
/// at write time at the application layer).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubjectGroupError {
/// Name doesn't match the RFC 5321 local-part shape (alnum start, then
/// alnum/dot/dash/underscore, max 64 chars).
InvalidName(String),
/// Cycle detected — the proposed membership would create a loop in the
/// group-of-groups graph.
CycleDetected(String),
/// Adding this member would exceed the maximum nesting depth.
DepthExceeded(String),
/// Attempt to mutate (delete, rename, or change membership of) an
/// immutable virtual group such as `Internal`.
VirtualImmutable(String),
/// General validation error.
ValidationError(String),
}
impl Display for SubjectGroupError {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
match self {
SubjectGroupError::InvalidName(msg) => write!(f, "Invalid group name: {}", msg),
SubjectGroupError::CycleDetected(msg) => write!(f, "Cycle detected: {}", msg),
SubjectGroupError::DepthExceeded(msg) => write!(f, "Group depth exceeded: {}", msg),
SubjectGroupError::VirtualImmutable(msg) => {
write!(f, "Virtual group is immutable: {}", msg)
}
SubjectGroupError::ValidationError(msg) => write!(f, "Validation error: {}", msg),
}
}
}
impl Error for SubjectGroupError {}
/// Type alias for SubjectGroup entity operation results.
pub type SubjectGroupResult<T> = Result<T, SubjectGroupError>;
// ============================================================================
// TESTS
// ============================================================================
@@ -254,5 +300,18 @@ mod tests {
assert_error::<ShareError>();
assert_error::<CalendarError>();
assert_error::<CalendarEventError>();
assert_error::<SubjectGroupError>();
}
#[test]
fn test_subject_group_error_display() {
let err = SubjectGroupError::InvalidName("Engineering Team".to_string());
assert_eq!(err.to_string(), "Invalid group name: Engineering Team");
let err = SubjectGroupError::CycleDetected("qa → engineering → qa".to_string());
assert_eq!(err.to_string(), "Cycle detected: qa → engineering → qa");
let err = SubjectGroupError::DepthExceeded("would reach depth 9".to_string());
assert_eq!(err.to_string(), "Group depth exceeded: would reach depth 9");
}
}