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");
}
}
+3 -1
View File
@@ -9,11 +9,13 @@ pub mod folder;
pub mod playlist;
pub mod session;
pub mod share;
pub mod subject_group;
pub mod trashed_item;
pub mod user;
// Re-exportar errores de entidades para facilitar el uso
pub use entity_errors::{
CalendarError, CalendarEventError, CalendarEventResult, CalendarResult, FileError, FileResult,
FolderError, FolderResult, ShareError, ShareResult, UserError, UserResult,
FolderError, FolderResult, ShareError, ShareResult, SubjectGroupError, SubjectGroupResult,
UserError, UserResult,
};
+199
View File
@@ -0,0 +1,199 @@
//! Subject group: ReBAC authorization principal.
//!
//! Subject groups are root-owned (no `owner_id`), globally named with an
//! RFC 5321 local-part shape, and able to contain users *or* other groups.
//! Grants in `storage.access_grants` with `subject_type = 'group'` reference
//! a row in `auth.subject_groups`.
//!
//! Cycle prevention and depth-cap (`MAX_GROUP_DEPTH`) are enforced at the
//! application layer at write time. The database schema enforces:
//! - case-insensitive uniqueness on `name` (CITEXT),
//! - the RFC 5321 local-part shape (CHECK regex),
//! - the XOR of (`member_user_id`, `member_group_id`) on memberships,
//! - no self-membership at the row level (a group can't list itself
//! directly as a child — longer cycles are application-layer concerns).
//!
//! See `migrations/20260612000000_subject_groups.sql`.
use chrono::{DateTime, Utc};
use uuid::Uuid;
pub use super::entity_errors::{SubjectGroupError, SubjectGroupResult};
/// Well-known UUID of the predefined `Internal` virtual group.
///
/// Hard-coded so application code can reference it without a runtime
/// lookup. Membership is implicit: every authenticated user is treated as
/// belonging to this group at evaluation time (see
/// `PgAclEngine::expand_subject`). Once the external-users work lands, this
/// will narrow to "every user with `is_external = false`".
pub const INTERNAL_GROUP_ID: Uuid =
Uuid::from_bytes([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]);
/// Maximum allowed nesting depth for groups-of-groups.
///
/// Enforced at write time inside `add_member`. The recursive CTE that
/// expands a user's transitive membership is bounded by this value, which
/// keeps authz checks predictable and prevents pathological graphs.
pub const MAX_GROUP_DEPTH: u8 = 8;
/// Maximum length of an RFC 5321 local-part.
const MAX_NAME_LEN: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubjectGroup {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub is_virtual: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// A member of a subject group: either an internal user or another group.
///
/// Externals and tokens may be added later; today only users and groups can
/// be members (matching the schema's tagged-union row).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GroupMember {
User(Uuid),
Group(Uuid),
}
impl SubjectGroup {
/// Construct a new group, validating the name shape.
///
/// The DB CHECK constraint is the authority; this validation exists so
/// the service layer can return a typed error before the round-trip.
pub fn new(name: &str, description: Option<String>) -> SubjectGroupResult<Self> {
Self::validate_name(name)?;
let now = Utc::now();
Ok(Self {
id: Uuid::new_v4(),
name: name.to_string(),
description,
is_virtual: false,
created_at: now,
updated_at: now,
})
}
/// Validate the name against the RFC 5321 local-part shape used by the
/// DB CHECK constraint: `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`.
pub fn validate_name(name: &str) -> SubjectGroupResult<()> {
if name.is_empty() {
return Err(SubjectGroupError::InvalidName("empty".to_string()));
}
if name.len() > MAX_NAME_LEN {
return Err(SubjectGroupError::InvalidName(format!(
"exceeds {} chars",
MAX_NAME_LEN
)));
}
// First char: must be alphanumeric ASCII (RFC 5321 is ASCII-only).
let mut chars = name.chars();
let first = chars.next().expect("non-empty above");
if !first.is_ascii_alphanumeric() {
return Err(SubjectGroupError::InvalidName(format!(
"must start with letter or digit: {}",
name
)));
}
// Remaining: alphanumeric or one of `.`, `-`, `_`.
for c in chars {
if !(c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') {
return Err(SubjectGroupError::InvalidName(format!(
"invalid character {:?} in {}",
c, name
)));
}
}
Ok(())
}
/// Whether mutations on this group are forbidden by virtue of it being a
/// system-managed virtual group (e.g. `Internal`).
pub fn is_immutable(&self) -> bool {
self.is_virtual
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_simple_rfc5321_local_part() {
assert!(SubjectGroup::validate_name("engineering").is_ok());
assert!(SubjectGroup::validate_name("eng-team_42").is_ok());
assert!(SubjectGroup::validate_name("a.b.c").is_ok());
assert!(SubjectGroup::validate_name("X").is_ok());
}
#[test]
fn rejects_empty_name() {
assert!(matches!(
SubjectGroup::validate_name(""),
Err(SubjectGroupError::InvalidName(_))
));
}
#[test]
fn rejects_space() {
assert!(matches!(
SubjectGroup::validate_name("Engineering Team"),
Err(SubjectGroupError::InvalidName(_))
));
}
#[test]
fn rejects_non_alnum_start() {
assert!(matches!(
SubjectGroup::validate_name(".dotfirst"),
Err(SubjectGroupError::InvalidName(_))
));
assert!(matches!(
SubjectGroup::validate_name("-dashfirst"),
Err(SubjectGroupError::InvalidName(_))
));
}
#[test]
fn rejects_unicode() {
assert!(matches!(
SubjectGroup::validate_name("équipe"),
Err(SubjectGroupError::InvalidName(_))
));
assert!(matches!(
SubjectGroup::validate_name("group🚀"),
Err(SubjectGroupError::InvalidName(_))
));
}
#[test]
fn rejects_too_long() {
let name = "a".repeat(65);
assert!(matches!(
SubjectGroup::validate_name(&name),
Err(SubjectGroupError::InvalidName(_))
));
}
#[test]
fn accepts_exactly_64_chars() {
let name = "a".repeat(64);
assert!(SubjectGroup::validate_name(&name).is_ok());
}
#[test]
fn internal_group_id_is_stable() {
// Match the well-known UUID seeded by migration 20260612000000.
assert_eq!(
INTERNAL_GROUP_ID.to_string(),
"00000000-0000-0000-0000-000000000001"
);
}
}