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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,6 @@ pub mod playlist_repository;
|
||||
pub mod session_repository;
|
||||
pub mod settings_repository;
|
||||
pub mod share_repository;
|
||||
pub mod subject_group_repository;
|
||||
pub mod trash_repository;
|
||||
pub mod user_repository;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Repository for ReBAC subject groups.
|
||||
//!
|
||||
//! See `src/domain/entities/subject_group.rs` for the entity and
|
||||
//! `migrations/20260612000000_subject_groups.sql` for the schema.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::entities::subject_group::{GroupMember, SubjectGroup};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SubjectGroupRepositoryError {
|
||||
#[error("Group not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("Group with name already exists: {0}")]
|
||||
NameAlreadyExists(String),
|
||||
#[error("Member already in group")]
|
||||
MemberAlreadyPresent,
|
||||
#[error("Member not in group")]
|
||||
MemberNotPresent,
|
||||
/// Attempting to add a group-member that would create a cycle.
|
||||
#[error("Adding this member would create a cycle: {0}")]
|
||||
Cycle(String),
|
||||
/// Attempting to add a group-member that would exceed `MAX_GROUP_DEPTH`.
|
||||
#[error("Adding this member would exceed the maximum nesting depth: {0}")]
|
||||
DepthExceeded(String),
|
||||
/// Attempt to mutate the immutable `Internal` virtual group.
|
||||
#[error("Virtual groups cannot be modified: {0}")]
|
||||
VirtualImmutable(String),
|
||||
/// Group name fails RFC 5321 local-part validation (mirrored at the DB
|
||||
/// via a CHECK constraint).
|
||||
#[error("Invalid group name: {0}")]
|
||||
InvalidName(String),
|
||||
#[error("Storage error: {0}")]
|
||||
StorageError(String),
|
||||
}
|
||||
|
||||
pub trait SubjectGroupRepository: Send + Sync + 'static {
|
||||
/// Create a new (non-virtual) group. Fails with `NameAlreadyExists` if
|
||||
/// the name (case-insensitive) is taken, or `InvalidName` if the DB
|
||||
/// CHECK rejects the shape.
|
||||
async fn create(
|
||||
&self,
|
||||
group: &SubjectGroup,
|
||||
) -> Result<SubjectGroup, SubjectGroupRepositoryError>;
|
||||
|
||||
/// Fetch a group by primary key. Returns `None` if missing — callers
|
||||
/// decide whether absence is an error.
|
||||
async fn get_by_id(
|
||||
&self,
|
||||
id: Uuid,
|
||||
) -> Result<Option<SubjectGroup>, SubjectGroupRepositoryError>;
|
||||
|
||||
/// Fetch a group by name. `CITEXT` makes this case-insensitive.
|
||||
async fn get_by_name(
|
||||
&self,
|
||||
name: &str,
|
||||
) -> Result<Option<SubjectGroup>, SubjectGroupRepositoryError>;
|
||||
|
||||
/// List groups; `name_query` is a substring match (ILIKE) when provided.
|
||||
/// Returns `(rows, total)` for pagination UIs.
|
||||
async fn list(
|
||||
&self,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
name_query: Option<&str>,
|
||||
) -> Result<(Vec<SubjectGroup>, u64), SubjectGroupRepositoryError>;
|
||||
|
||||
/// Same as `list`, but each row is paired with its direct-member count.
|
||||
/// Used by the management UI to show "(N members)" on each row without
|
||||
/// the N+1 query of calling `count_members` per row. The count is the
|
||||
/// LEFT-JOIN aggregate from `auth.subject_group_members` so the
|
||||
/// implementation pulls everything in a single SQL round-trip.
|
||||
async fn list_with_counts(
|
||||
&self,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
name_query: Option<&str>,
|
||||
) -> Result<(Vec<(SubjectGroup, i64)>, u64), SubjectGroupRepositoryError>;
|
||||
|
||||
/// Count direct members (users + nested groups) of a single group.
|
||||
/// Used by single-item endpoints (create / get / update) so the response
|
||||
/// DTO can include `member_count` without a separate frontend round-trip.
|
||||
async fn count_members(&self, id: Uuid) -> Result<i64, SubjectGroupRepositoryError>;
|
||||
|
||||
/// Rename the group. Fails on collision or invalid shape.
|
||||
async fn rename(
|
||||
&self,
|
||||
id: Uuid,
|
||||
new_name: &str,
|
||||
) -> Result<SubjectGroup, SubjectGroupRepositoryError>;
|
||||
|
||||
/// Delete the group. Cascades to `subject_group_members` and to
|
||||
/// `storage.access_grants` rows referencing this group as subject (via
|
||||
/// the application service — there is no FK between `access_grants` and
|
||||
/// `subject_groups`, so the service performs the cascade explicitly in
|
||||
/// the same transaction).
|
||||
async fn delete(&self, id: Uuid) -> Result<(), SubjectGroupRepositoryError>;
|
||||
|
||||
/// Add a member (user or another group). Performs cycle + depth checks
|
||||
/// inside the same transaction (`SELECT ... FOR UPDATE` on the parent
|
||||
/// row to prevent racing concurrent adds from squeezing under the limit
|
||||
/// individually).
|
||||
async fn add_member(
|
||||
&self,
|
||||
group_id: Uuid,
|
||||
member: GroupMember,
|
||||
added_by: Uuid,
|
||||
) -> Result<(), SubjectGroupRepositoryError>;
|
||||
|
||||
/// Remove a member. No-op-safe: returns `MemberNotPresent` if the row
|
||||
/// doesn't exist.
|
||||
async fn remove_member(
|
||||
&self,
|
||||
group_id: Uuid,
|
||||
member: GroupMember,
|
||||
) -> Result<(), SubjectGroupRepositoryError>;
|
||||
|
||||
/// Direct members of `group_id` (one level only, not transitive).
|
||||
async fn list_direct_members(
|
||||
&self,
|
||||
group_id: Uuid,
|
||||
) -> Result<Vec<GroupMember>, SubjectGroupRepositoryError>;
|
||||
|
||||
/// All users transitively in `group_id` (debug / audit / admin views).
|
||||
async fn list_transitive_users(
|
||||
&self,
|
||||
group_id: Uuid,
|
||||
) -> Result<Vec<Uuid>, SubjectGroupRepositoryError>;
|
||||
|
||||
/// All groups `user_id` belongs to transitively. This is the hot path
|
||||
/// driven by `PgAclEngine::expand_subject` on every cache miss — the
|
||||
/// `Internal` virtual group is NOT included here (the engine adds it).
|
||||
async fn groups_for_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> Result<HashSet<Uuid>, SubjectGroupRepositoryError>;
|
||||
}
|
||||
Reference in New Issue
Block a user