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:
@@ -23,6 +23,7 @@ pub mod share_browse_service;
|
||||
pub mod share_service;
|
||||
pub mod storage_settings_service;
|
||||
pub mod storage_usage_service;
|
||||
pub mod subject_group_service;
|
||||
pub mod trash_service;
|
||||
pub mod wopi_lock_service;
|
||||
pub mod wopi_token_service;
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
//! Subject group application service.
|
||||
//!
|
||||
//! Orchestrates CRUD and membership for ReBAC subject groups on top of the
|
||||
//! `SubjectGroupRepository`. This is where:
|
||||
//! - Name validation runs (defence-in-depth alongside the DB CHECK).
|
||||
//! - Virtual groups (e.g. `Internal`) are protected from mutation.
|
||||
//! - Audit events are emitted via `tracing::info!(target = "audit", ...)`.
|
||||
//! - Cascading delete of `storage.access_grants` rows referencing this
|
||||
//! group runs in the same transaction as the group delete.
|
||||
//!
|
||||
//! See `migrations/20260612000000_subject_groups.sql` for the schema.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::subject_group::{
|
||||
GroupMember, INTERNAL_GROUP_ID, SubjectGroup, SubjectGroupError,
|
||||
};
|
||||
use crate::domain::repositories::subject_group_repository::{
|
||||
SubjectGroupRepository, SubjectGroupRepositoryError,
|
||||
};
|
||||
use crate::infrastructure::repositories::pg::SubjectGroupPgRepository;
|
||||
|
||||
pub struct SubjectGroupService {
|
||||
repo: Arc<SubjectGroupPgRepository>,
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl SubjectGroupService {
|
||||
pub fn new(repo: Arc<SubjectGroupPgRepository>, pool: Arc<PgPool>) -> Self {
|
||||
Self { repo, pool }
|
||||
}
|
||||
|
||||
/// Create a new group. Validates the name (RFC 5321 local-part shape)
|
||||
/// at the domain layer before the round-trip; the DB CHECK constraint
|
||||
/// is the ultimate authority.
|
||||
pub async fn create(
|
||||
&self,
|
||||
name: &str,
|
||||
description: Option<String>,
|
||||
caller_id: Uuid,
|
||||
) -> Result<SubjectGroup, DomainError> {
|
||||
let group = SubjectGroup::new(name, description).map_err(map_entity_err)?;
|
||||
let saved = self.repo.create(&group).await.map_err(map_repo_err)?;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "group.created",
|
||||
group_id = %saved.id,
|
||||
name = %saved.name,
|
||||
created_by = %caller_id,
|
||||
);
|
||||
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
pub async fn get_by_id(&self, id: Uuid) -> Result<SubjectGroup, DomainError> {
|
||||
match self.repo.get_by_id(id).await.map_err(map_repo_err)? {
|
||||
Some(g) => Ok(g),
|
||||
None => Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"SubjectGroup",
|
||||
format!("group {} not found", id),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
&self,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
name_query: Option<&str>,
|
||||
) -> Result<(Vec<SubjectGroup>, u64), DomainError> {
|
||||
self.repo
|
||||
.list(limit, offset, name_query)
|
||||
.await
|
||||
.map_err(map_repo_err)
|
||||
}
|
||||
|
||||
/// Same as `list`, with the direct-member count attached to each row.
|
||||
/// Used by the management UI; the share-dialog search path stays on the
|
||||
/// lighter `search_for_share` which doesn't need counts.
|
||||
pub async fn list_with_counts(
|
||||
&self,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
name_query: Option<&str>,
|
||||
) -> Result<(Vec<(SubjectGroup, i64)>, u64), DomainError> {
|
||||
self.repo
|
||||
.list_with_counts(limit, offset, name_query)
|
||||
.await
|
||||
.map_err(map_repo_err)
|
||||
}
|
||||
|
||||
/// Direct-member count for a single group. Cheap (one `COUNT(*)`); used
|
||||
/// by create / get / update endpoints so the response DTO carries the
|
||||
/// same `member_count` field as the list view.
|
||||
pub async fn count_members(&self, id: Uuid) -> Result<i64, DomainError> {
|
||||
self.repo.count_members(id).await.map_err(map_repo_err)
|
||||
}
|
||||
|
||||
/// Search by name prefix/substring. Virtual groups (Internal, plus any
|
||||
/// future predefined entries) are included so the share-dialog
|
||||
/// autocomplete picks them up automatically — no frontend change is
|
||||
/// needed when a new virtual group is added server-side. The repository
|
||||
/// returns virtual groups first so they're discoverable when the query
|
||||
/// is empty / short.
|
||||
pub async fn search_for_share(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
) -> Result<Vec<SubjectGroup>, DomainError> {
|
||||
let (rows, _total) = self
|
||||
.repo
|
||||
.list(limit, 0, Some(query))
|
||||
.await
|
||||
.map_err(map_repo_err)?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn rename(
|
||||
&self,
|
||||
id: Uuid,
|
||||
new_name: &str,
|
||||
caller_id: Uuid,
|
||||
) -> Result<SubjectGroup, DomainError> {
|
||||
// Block mutation on virtual groups (the Internal sentinel).
|
||||
let existing = self.get_by_id(id).await?;
|
||||
if existing.is_virtual {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"SubjectGroup",
|
||||
"virtual groups cannot be modified".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
SubjectGroup::validate_name(new_name).map_err(map_entity_err)?;
|
||||
let renamed = self.repo.rename(id, new_name).await.map_err(map_repo_err)?;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "group.renamed",
|
||||
group_id = %renamed.id,
|
||||
old_name = %existing.name,
|
||||
new_name = %renamed.name,
|
||||
by = %caller_id,
|
||||
);
|
||||
|
||||
Ok(renamed)
|
||||
}
|
||||
|
||||
/// Delete the group; cascades to:
|
||||
/// - `auth.subject_group_members` rows (FK CASCADE).
|
||||
/// - `storage.access_grants` rows where `subject_type='group'` and
|
||||
/// `subject_id = id` (handled here, no FK exists between
|
||||
/// `access_grants` and `subject_groups`).
|
||||
pub async fn delete(&self, id: Uuid, caller_id: Uuid) -> Result<(), DomainError> {
|
||||
let existing = self.get_by_id(id).await?;
|
||||
if existing.is_virtual {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"SubjectGroup",
|
||||
"virtual groups cannot be modified".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Atomically delete grants pointing at this group, then the group
|
||||
// itself. If either fails, both roll back.
|
||||
let mut tx = self.pool.begin().await.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"SubjectGroup",
|
||||
format!("begin tx: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let grants_deleted = sqlx::query(
|
||||
"DELETE FROM storage.access_grants
|
||||
WHERE subject_type = 'group' AND subject_id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"SubjectGroup",
|
||||
format!("cascade-delete grants: {}", e),
|
||||
)
|
||||
})?
|
||||
.rows_affected();
|
||||
|
||||
let removed = sqlx::query("DELETE FROM auth.subject_groups WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"SubjectGroup",
|
||||
format!("delete group: {}", e),
|
||||
)
|
||||
})?
|
||||
.rows_affected();
|
||||
|
||||
if removed == 0 {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"SubjectGroup",
|
||||
format!("group {} not found", id),
|
||||
));
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"SubjectGroup",
|
||||
format!("commit: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "group.deleted",
|
||||
group_id = %id,
|
||||
name = %existing.name,
|
||||
grants_cascade_deleted = grants_deleted,
|
||||
by = %caller_id,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_member(
|
||||
&self,
|
||||
group_id: Uuid,
|
||||
member: GroupMember,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
if group_id == INTERNAL_GROUP_ID {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"SubjectGroup",
|
||||
"Internal group membership is implicit and cannot be edited".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.repo
|
||||
.add_member(group_id, member, caller_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// Emit a security-relevant audit event on cycle / depth
|
||||
// rejections so abusive admin behaviour is captured.
|
||||
match &e {
|
||||
SubjectGroupRepositoryError::Cycle(msg) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "group.cycle_rejected",
|
||||
group_id = %group_id,
|
||||
member = ?member,
|
||||
detail = %msg,
|
||||
by = %caller_id,
|
||||
);
|
||||
}
|
||||
SubjectGroupRepositoryError::DepthExceeded(msg) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "group.depth_exceeded",
|
||||
group_id = %group_id,
|
||||
member = ?member,
|
||||
detail = %msg,
|
||||
by = %caller_id,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
map_repo_err(e)
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "group.member_added",
|
||||
group_id = %group_id,
|
||||
member = ?member,
|
||||
by = %caller_id,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_member(
|
||||
&self,
|
||||
group_id: Uuid,
|
||||
member: GroupMember,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
if group_id == INTERNAL_GROUP_ID {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"SubjectGroup",
|
||||
"Internal group membership is implicit and cannot be edited".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.repo
|
||||
.remove_member(group_id, member)
|
||||
.await
|
||||
.map_err(map_repo_err)?;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "group.member_removed",
|
||||
group_id = %group_id,
|
||||
member = ?member,
|
||||
by = %caller_id,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_direct_members(
|
||||
&self,
|
||||
group_id: Uuid,
|
||||
) -> Result<Vec<GroupMember>, DomainError> {
|
||||
self.repo
|
||||
.list_direct_members(group_id)
|
||||
.await
|
||||
.map_err(map_repo_err)
|
||||
}
|
||||
|
||||
pub async fn list_transitive_users(&self, group_id: Uuid) -> Result<Vec<Uuid>, DomainError> {
|
||||
self.repo
|
||||
.list_transitive_users(group_id)
|
||||
.await
|
||||
.map_err(map_repo_err)
|
||||
}
|
||||
|
||||
/// Hot path used by `PgAclEngine::expand_subject`. Returns the set of
|
||||
/// groups `user_id` belongs to transitively (excluding the implicit
|
||||
/// `INTERNAL_GROUP_ID` — the caller adds that).
|
||||
pub async fn groups_for_user(&self, user_id: Uuid) -> Result<HashSet<Uuid>, DomainError> {
|
||||
self.repo
|
||||
.groups_for_user(user_id)
|
||||
.await
|
||||
.map_err(map_repo_err)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_entity_err(e: SubjectGroupError) -> DomainError {
|
||||
let (kind, msg) = match e {
|
||||
SubjectGroupError::InvalidName(m) => (ErrorKind::InvalidInput, m),
|
||||
SubjectGroupError::CycleDetected(m) => (ErrorKind::InvalidInput, m),
|
||||
SubjectGroupError::DepthExceeded(m) => (ErrorKind::InvalidInput, m),
|
||||
SubjectGroupError::VirtualImmutable(m) => (ErrorKind::AccessDenied, m),
|
||||
SubjectGroupError::ValidationError(m) => (ErrorKind::InvalidInput, m),
|
||||
};
|
||||
DomainError::new(kind, "SubjectGroup", msg)
|
||||
}
|
||||
|
||||
fn map_repo_err(e: SubjectGroupRepositoryError) -> DomainError {
|
||||
let (kind, msg) = match e {
|
||||
SubjectGroupRepositoryError::NotFound(m) => (ErrorKind::NotFound, m),
|
||||
SubjectGroupRepositoryError::NameAlreadyExists(m) => (ErrorKind::AlreadyExists, m),
|
||||
SubjectGroupRepositoryError::InvalidName(m) => (ErrorKind::InvalidInput, m),
|
||||
SubjectGroupRepositoryError::Cycle(m) => (ErrorKind::InvalidInput, m),
|
||||
SubjectGroupRepositoryError::DepthExceeded(m) => (ErrorKind::InvalidInput, m),
|
||||
SubjectGroupRepositoryError::VirtualImmutable(m) => (ErrorKind::AccessDenied, m),
|
||||
SubjectGroupRepositoryError::MemberAlreadyPresent => (
|
||||
ErrorKind::AlreadyExists,
|
||||
"member already in group".to_string(),
|
||||
),
|
||||
SubjectGroupRepositoryError::MemberNotPresent => {
|
||||
(ErrorKind::NotFound, "member not in group".to_string())
|
||||
}
|
||||
SubjectGroupRepositoryError::StorageError(m) => (ErrorKind::InternalError, m),
|
||||
};
|
||||
DomainError::new(kind, "SubjectGroup", msg)
|
||||
}
|
||||
+18
-1
@@ -638,10 +638,16 @@ impl AppServiceFactory {
|
||||
|
||||
// 3a. Authorization engine — must exist before application services
|
||||
// because services hold an Arc<PgAclEngine> for ReBAC checks.
|
||||
// SubjectGroupPgRepository is constructed here too so the engine can
|
||||
// expand a user's transitive group set on cache misses.
|
||||
let subject_group_repo = Arc::new(
|
||||
crate::infrastructure::repositories::pg::SubjectGroupPgRepository::new(pool.clone()),
|
||||
);
|
||||
let authorization = build_authorization_engine(
|
||||
pool.clone(),
|
||||
repos.folder_repository.clone(),
|
||||
repos.file_read_repository.clone(),
|
||||
subject_group_repo.clone(),
|
||||
);
|
||||
|
||||
// 3b. Trash service (needed before application services)
|
||||
@@ -822,6 +828,12 @@ impl AppServiceFactory {
|
||||
webdav_lock_store:
|
||||
crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(),
|
||||
authorization,
|
||||
subject_group_service: Some(Arc::new(
|
||||
crate::application::services::subject_group_service::SubjectGroupService::new(
|
||||
subject_group_repo.clone(),
|
||||
pool.clone(),
|
||||
),
|
||||
)),
|
||||
};
|
||||
|
||||
// 9b. Wire admin settings service when auth is available
|
||||
@@ -1147,6 +1159,10 @@ pub struct AppState {
|
||||
/// an enum dispatcher or `Arc<dyn AuthorizationEngine>` (with
|
||||
/// `async_trait` boxing).
|
||||
pub authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||
/// ReBAC subject-group management (CRUD + membership). `None` when the
|
||||
/// auth subsystem is not configured.
|
||||
pub subject_group_service:
|
||||
Option<Arc<crate::application::services::subject_group_service::SubjectGroupService>>,
|
||||
}
|
||||
|
||||
// All AppState construction is done via struct literal in build_app_state().
|
||||
@@ -1162,6 +1178,7 @@ fn build_authorization_engine(
|
||||
file_repo: Arc<
|
||||
crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository,
|
||||
>,
|
||||
group_repo: Arc<crate::infrastructure::repositories::pg::SubjectGroupPgRepository>,
|
||||
) -> Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine> {
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
|
||||
@@ -1173,5 +1190,5 @@ fn build_authorization_engine(
|
||||
"OXICLOUD_AUTHZ_ENGINE={other:?} is not yet supported. Only 'postgres' is implemented; leave the variable unset to use the default."
|
||||
);
|
||||
}
|
||||
Arc::new(PgAclEngine::new(pool, folder_repo, file_repo))
|
||||
Arc::new(PgAclEngine::new(pool, folder_repo, file_repo, group_repo))
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ mod recent_items_pg_repository;
|
||||
mod session_pg_repository;
|
||||
mod settings_pg_repository;
|
||||
mod share_pg_repository;
|
||||
mod subject_group_pg_repository;
|
||||
mod transaction_utils;
|
||||
mod user_pg_repository;
|
||||
|
||||
@@ -44,6 +45,7 @@ pub use recent_items_pg_repository::RecentItemsPgRepository;
|
||||
pub use session_pg_repository::SessionPgRepository;
|
||||
pub use settings_pg_repository::SettingsPgRepository;
|
||||
pub use share_pg_repository::SharePgRepository;
|
||||
pub use subject_group_pg_repository::SubjectGroupPgRepository;
|
||||
pub use trash_db_repository::TrashDbRepository;
|
||||
pub use user_pg_repository::UserPgRepository;
|
||||
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
//! Postgres implementation of `SubjectGroupRepository`.
|
||||
//!
|
||||
//! Two queries are non-trivial and deserve a read pass:
|
||||
//! - **Cycle check** (write-time, inside `add_member` when adding a
|
||||
//! group-member): walks child-edges from the candidate; if the parent
|
||||
//! appears in the descendants, reject.
|
||||
//! - **Transitive expansion** (`groups_for_user`): hot path on every
|
||||
//! authz cache miss; walks parent-edges from the user's direct
|
||||
//! memberships upward through nested groups.
|
||||
//!
|
||||
//! Depth-cap (`MAX_GROUP_DEPTH = 8`) is enforced at write time inside the
|
||||
//! same transaction as the membership insert.
|
||||
//!
|
||||
//! See `migrations/20260612000000_subject_groups.sql` for the schema.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use sqlx::{PgPool, Row, types::Uuid};
|
||||
|
||||
use super::like_escape;
|
||||
use crate::domain::entities::subject_group::{GroupMember, MAX_GROUP_DEPTH, SubjectGroup};
|
||||
use crate::domain::repositories::subject_group_repository::{
|
||||
SubjectGroupRepository, SubjectGroupRepositoryError,
|
||||
};
|
||||
|
||||
pub struct SubjectGroupPgRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl SubjectGroupPgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_sqlx_err(context: &'static str, e: sqlx::Error) -> SubjectGroupRepositoryError {
|
||||
// Recognise common Postgres errors and translate to typed variants.
|
||||
if let sqlx::Error::Database(ref dberr) = e
|
||||
&& let Some(code) = dberr.code()
|
||||
{
|
||||
match code.as_ref() {
|
||||
// unique_violation — name collision (or duplicate member, but
|
||||
// the caller already handles that case via UNIQUE indexes
|
||||
// returning the same code).
|
||||
"23505" => {
|
||||
return SubjectGroupRepositoryError::NameAlreadyExists(dberr.to_string());
|
||||
}
|
||||
// check_violation — RFC 5321 regex CHECK failed.
|
||||
"23514" => return SubjectGroupRepositoryError::InvalidName(dberr.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
SubjectGroupRepositoryError::StorageError(format!("{}: {}", context, e))
|
||||
}
|
||||
|
||||
fn row_to_group(row: &sqlx::postgres::PgRow) -> SubjectGroup {
|
||||
SubjectGroup {
|
||||
id: row.get::<Uuid, _>("id"),
|
||||
name: row.get::<String, _>("name"),
|
||||
description: row.get::<Option<String>, _>("description"),
|
||||
is_virtual: row.get::<bool, _>("is_virtual"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SubjectGroupRepository for SubjectGroupPgRepository {
|
||||
async fn create(
|
||||
&self,
|
||||
group: &SubjectGroup,
|
||||
) -> Result<SubjectGroup, SubjectGroupRepositoryError> {
|
||||
let row = sqlx::query(
|
||||
"INSERT INTO auth.subject_groups (id, name, description, is_virtual, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, false, $4, $5)
|
||||
RETURNING id, name, description, is_virtual, created_at, updated_at",
|
||||
)
|
||||
.bind(group.id)
|
||||
.bind(&group.name)
|
||||
.bind(&group.description)
|
||||
.bind(group.created_at)
|
||||
.bind(group.updated_at)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("create subject_group", e))?;
|
||||
|
||||
Ok(Self::row_to_group(&row))
|
||||
}
|
||||
|
||||
async fn get_by_id(
|
||||
&self,
|
||||
id: Uuid,
|
||||
) -> Result<Option<SubjectGroup>, SubjectGroupRepositoryError> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, name, description, is_virtual, created_at, updated_at
|
||||
FROM auth.subject_groups WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("get_by_id", e))?;
|
||||
|
||||
Ok(row.as_ref().map(Self::row_to_group))
|
||||
}
|
||||
|
||||
async fn get_by_name(
|
||||
&self,
|
||||
name: &str,
|
||||
) -> Result<Option<SubjectGroup>, SubjectGroupRepositoryError> {
|
||||
// CITEXT matches case-insensitively — no need for LOWER() here.
|
||||
let row = sqlx::query(
|
||||
"SELECT id, name, description, is_virtual, created_at, updated_at
|
||||
FROM auth.subject_groups WHERE name = $1",
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("get_by_name", e))?;
|
||||
|
||||
Ok(row.as_ref().map(Self::row_to_group))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
name_query: Option<&str>,
|
||||
) -> Result<(Vec<SubjectGroup>, u64), SubjectGroupRepositoryError> {
|
||||
// Two queries: one for the page, one for the total count. The query
|
||||
// is small and frequent; a window function would add complexity for
|
||||
// no measurable win.
|
||||
let (sql_page, sql_count, pattern) = match name_query {
|
||||
Some(q) => {
|
||||
let pat = like_escape(q);
|
||||
(
|
||||
"SELECT id, name, description, is_virtual, created_at, updated_at
|
||||
FROM auth.subject_groups
|
||||
WHERE name ILIKE $1
|
||||
ORDER BY is_virtual DESC, name
|
||||
LIMIT $2 OFFSET $3"
|
||||
.to_string(),
|
||||
"SELECT COUNT(*) FROM auth.subject_groups WHERE name ILIKE $1".to_string(),
|
||||
Some(pat),
|
||||
)
|
||||
}
|
||||
None => (
|
||||
"SELECT id, name, description, is_virtual, created_at, updated_at
|
||||
FROM auth.subject_groups
|
||||
ORDER BY is_virtual DESC, name
|
||||
LIMIT $1 OFFSET $2"
|
||||
.to_string(),
|
||||
"SELECT COUNT(*) FROM auth.subject_groups".to_string(),
|
||||
None,
|
||||
),
|
||||
};
|
||||
|
||||
let rows = if let Some(ref p) = pattern {
|
||||
sqlx::query(&sql_page)
|
||||
.bind(p)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query(&sql_page)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| Self::map_sqlx_err("list page", e))?;
|
||||
|
||||
let total: i64 = if let Some(ref p) = pattern {
|
||||
sqlx::query_scalar(&sql_count)
|
||||
.bind(p)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_scalar(&sql_count)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| Self::map_sqlx_err("list count", e))?;
|
||||
|
||||
Ok((rows.iter().map(Self::row_to_group).collect(), total as u64))
|
||||
}
|
||||
|
||||
async fn list_with_counts(
|
||||
&self,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
name_query: Option<&str>,
|
||||
) -> Result<(Vec<(SubjectGroup, i64)>, u64), SubjectGroupRepositoryError> {
|
||||
// Single SQL: groups + COUNT of direct members per group, via LEFT JOIN
|
||||
// on `auth.subject_group_members`. No N+1; one round-trip for the
|
||||
// page, a second for the unfiltered total (matches `list`).
|
||||
let (sql_page, sql_count, pattern) = match name_query {
|
||||
Some(q) => {
|
||||
let pat = like_escape(q);
|
||||
(
|
||||
"SELECT g.id, g.name, g.description, g.is_virtual,
|
||||
g.created_at, g.updated_at,
|
||||
COUNT(m.group_id) AS member_count
|
||||
FROM auth.subject_groups g
|
||||
LEFT JOIN auth.subject_group_members m ON m.group_id = g.id
|
||||
WHERE g.name ILIKE $1
|
||||
GROUP BY g.id
|
||||
ORDER BY g.is_virtual DESC, g.name
|
||||
LIMIT $2 OFFSET $3"
|
||||
.to_string(),
|
||||
"SELECT COUNT(*) FROM auth.subject_groups WHERE name ILIKE $1".to_string(),
|
||||
Some(pat),
|
||||
)
|
||||
}
|
||||
None => (
|
||||
"SELECT g.id, g.name, g.description, g.is_virtual,
|
||||
g.created_at, g.updated_at,
|
||||
COUNT(m.group_id) AS member_count
|
||||
FROM auth.subject_groups g
|
||||
LEFT JOIN auth.subject_group_members m ON m.group_id = g.id
|
||||
GROUP BY g.id
|
||||
ORDER BY g.is_virtual DESC, g.name
|
||||
LIMIT $1 OFFSET $2"
|
||||
.to_string(),
|
||||
"SELECT COUNT(*) FROM auth.subject_groups".to_string(),
|
||||
None,
|
||||
),
|
||||
};
|
||||
|
||||
let rows = if let Some(ref p) = pattern {
|
||||
sqlx::query(&sql_page)
|
||||
.bind(p)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query(&sql_page)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| Self::map_sqlx_err("list_with_counts page", e))?;
|
||||
|
||||
let total: i64 = if let Some(ref p) = pattern {
|
||||
sqlx::query_scalar(&sql_count)
|
||||
.bind(p)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_scalar(&sql_count)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| Self::map_sqlx_err("list_with_counts total", e))?;
|
||||
|
||||
let items = rows
|
||||
.iter()
|
||||
.map(|r| (Self::row_to_group(r), r.get::<i64, _>("member_count")))
|
||||
.collect();
|
||||
|
||||
Ok((items, total as u64))
|
||||
}
|
||||
|
||||
async fn count_members(&self, id: Uuid) -> Result<i64, SubjectGroupRepositoryError> {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM auth.subject_group_members WHERE group_id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("count_members", e))?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn rename(
|
||||
&self,
|
||||
id: Uuid,
|
||||
new_name: &str,
|
||||
) -> Result<SubjectGroup, SubjectGroupRepositoryError> {
|
||||
let row = sqlx::query(
|
||||
"UPDATE auth.subject_groups
|
||||
SET name = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, name, description, is_virtual, created_at, updated_at",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(new_name)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("rename", e))?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Self::row_to_group(&r)),
|
||||
None => Err(SubjectGroupRepositoryError::NotFound(id.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), SubjectGroupRepositoryError> {
|
||||
// The application service is responsible for clearing related
|
||||
// `storage.access_grants` rows in the same transaction (there's no
|
||||
// FK between access_grants and subject_groups). The subject_group_members
|
||||
// rows cascade automatically via FK.
|
||||
let result = sqlx::query("DELETE FROM auth.subject_groups WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("delete", e))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(SubjectGroupRepositoryError::NotFound(id.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_member(
|
||||
&self,
|
||||
group_id: Uuid,
|
||||
member: GroupMember,
|
||||
added_by: Uuid,
|
||||
) -> Result<(), SubjectGroupRepositoryError> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("add_member: begin tx", e))?;
|
||||
|
||||
// Lock the parent row to prevent racing concurrent adds from each
|
||||
// squeezing under the cycle/depth limits.
|
||||
let exists: Option<(Uuid,)> =
|
||||
sqlx::query_as("SELECT id FROM auth.subject_groups WHERE id = $1 FOR UPDATE")
|
||||
.bind(group_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("add_member: lock parent", e))?;
|
||||
if exists.is_none() {
|
||||
return Err(SubjectGroupRepositoryError::NotFound(group_id.to_string()));
|
||||
}
|
||||
|
||||
match member {
|
||||
GroupMember::User(user_id) => {
|
||||
// Plain insert. Unique index catches duplicates.
|
||||
let res = sqlx::query(
|
||||
"INSERT INTO auth.subject_group_members
|
||||
(group_id, member_user_id, added_by)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(group_id)
|
||||
.bind(user_id)
|
||||
.bind(added_by)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("add_member: insert user", e))?;
|
||||
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(SubjectGroupRepositoryError::MemberAlreadyPresent);
|
||||
}
|
||||
}
|
||||
GroupMember::Group(member_group_id) => {
|
||||
if member_group_id == group_id {
|
||||
return Err(SubjectGroupRepositoryError::Cycle(
|
||||
"group cannot contain itself".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// ── Cycle check ─────────────────────────────────────────
|
||||
// Adding member_group_id=$child to group_id=$parent creates
|
||||
// a cycle iff $parent is reachable by walking child-edges
|
||||
// from $child. Use a bounded recursion (UNION de-dups).
|
||||
let cycle: Option<(i32,)> = sqlx::query_as(
|
||||
"WITH RECURSIVE descendants AS (
|
||||
SELECT member_group_id AS g
|
||||
FROM auth.subject_group_members
|
||||
WHERE group_id = $1 AND member_group_id IS NOT NULL
|
||||
UNION
|
||||
SELECT m.member_group_id
|
||||
FROM auth.subject_group_members m
|
||||
JOIN descendants d ON m.group_id = d.g
|
||||
WHERE m.member_group_id IS NOT NULL
|
||||
)
|
||||
SELECT 1 FROM descendants WHERE g = $2 LIMIT 1",
|
||||
)
|
||||
.bind(member_group_id)
|
||||
.bind(group_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("add_member: cycle check", e))?;
|
||||
if cycle.is_some() {
|
||||
return Err(SubjectGroupRepositoryError::Cycle(format!(
|
||||
"{} → {}",
|
||||
group_id, member_group_id
|
||||
)));
|
||||
}
|
||||
|
||||
// ── Depth check ─────────────────────────────────────────
|
||||
// The longest path from $parent after the mutation =
|
||||
// max(longest path from existing descendants, 1 + longest
|
||||
// path under $child). Compute both with the same CTE,
|
||||
// pretending the new edge already exists.
|
||||
let depth: Option<(i32,)> = sqlx::query_as(
|
||||
"WITH RECURSIVE path AS (
|
||||
-- existing depth from this group downward
|
||||
SELECT member_group_id AS g, 1 AS depth
|
||||
FROM auth.subject_group_members
|
||||
WHERE group_id = $1 AND member_group_id IS NOT NULL
|
||||
UNION ALL
|
||||
-- proposed new edge
|
||||
SELECT $2::uuid AS g, 1 AS depth
|
||||
UNION ALL
|
||||
SELECT m.member_group_id, p.depth + 1
|
||||
FROM auth.subject_group_members m
|
||||
JOIN path p ON m.group_id = p.g
|
||||
WHERE m.member_group_id IS NOT NULL
|
||||
)
|
||||
SELECT MAX(depth) FROM path",
|
||||
)
|
||||
.bind(group_id)
|
||||
.bind(member_group_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("add_member: depth check", e))?;
|
||||
|
||||
let max_depth = depth.map(|d| d.0).unwrap_or(0);
|
||||
if (max_depth as u8) > MAX_GROUP_DEPTH {
|
||||
return Err(SubjectGroupRepositoryError::DepthExceeded(format!(
|
||||
"would reach depth {} (max {})",
|
||||
max_depth, MAX_GROUP_DEPTH
|
||||
)));
|
||||
}
|
||||
|
||||
// ── Insert ──────────────────────────────────────────────
|
||||
let res = sqlx::query(
|
||||
"INSERT INTO auth.subject_group_members
|
||||
(group_id, member_group_id, added_by)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(group_id)
|
||||
.bind(member_group_id)
|
||||
.bind(added_by)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("add_member: insert group", e))?;
|
||||
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(SubjectGroupRepositoryError::MemberAlreadyPresent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("add_member: commit", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_member(
|
||||
&self,
|
||||
group_id: Uuid,
|
||||
member: GroupMember,
|
||||
) -> Result<(), SubjectGroupRepositoryError> {
|
||||
let res = match member {
|
||||
GroupMember::User(uid) => sqlx::query(
|
||||
"DELETE FROM auth.subject_group_members
|
||||
WHERE group_id = $1 AND member_user_id = $2",
|
||||
)
|
||||
.bind(group_id)
|
||||
.bind(uid),
|
||||
GroupMember::Group(gid) => sqlx::query(
|
||||
"DELETE FROM auth.subject_group_members
|
||||
WHERE group_id = $1 AND member_group_id = $2",
|
||||
)
|
||||
.bind(group_id)
|
||||
.bind(gid),
|
||||
}
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("remove_member", e))?;
|
||||
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(SubjectGroupRepositoryError::MemberNotPresent);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_direct_members(
|
||||
&self,
|
||||
group_id: Uuid,
|
||||
) -> Result<Vec<GroupMember>, SubjectGroupRepositoryError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT member_user_id, member_group_id
|
||||
FROM auth.subject_group_members
|
||||
WHERE group_id = $1",
|
||||
)
|
||||
.bind(group_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("list_direct_members", e))?;
|
||||
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let user_id: Option<Uuid> = row.get("member_user_id");
|
||||
let group_id: Option<Uuid> = row.get("member_group_id");
|
||||
match (user_id, group_id) {
|
||||
(Some(uid), None) => out.push(GroupMember::User(uid)),
|
||||
(None, Some(gid)) => out.push(GroupMember::Group(gid)),
|
||||
_ => {
|
||||
// XOR check at the schema level guarantees we never hit
|
||||
// this branch — log defensively if we do.
|
||||
tracing::warn!(
|
||||
"subject_group_members row violates XOR invariant (user={:?}, group={:?})",
|
||||
user_id,
|
||||
group_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn list_transitive_users(
|
||||
&self,
|
||||
group_id: Uuid,
|
||||
) -> Result<Vec<Uuid>, SubjectGroupRepositoryError> {
|
||||
// Walk child-edges from `group_id` to find every user transitively
|
||||
// a member. Used by debug / audit endpoints.
|
||||
let rows = sqlx::query(
|
||||
"WITH RECURSIVE descendants AS (
|
||||
SELECT $1::uuid AS g
|
||||
UNION
|
||||
SELECT m.member_group_id
|
||||
FROM auth.subject_group_members m
|
||||
JOIN descendants d ON m.group_id = d.g
|
||||
WHERE m.member_group_id IS NOT NULL
|
||||
)
|
||||
SELECT DISTINCT m.member_user_id AS user_id
|
||||
FROM auth.subject_group_members m
|
||||
JOIN descendants d ON m.group_id = d.g
|
||||
WHERE m.member_user_id IS NOT NULL",
|
||||
)
|
||||
.bind(group_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("list_transitive_users", e))?;
|
||||
|
||||
Ok(rows.iter().map(|r| r.get::<Uuid, _>("user_id")).collect())
|
||||
}
|
||||
|
||||
async fn groups_for_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> Result<HashSet<Uuid>, SubjectGroupRepositoryError> {
|
||||
// The hot path. PgAclEngine::expand_subject calls this on every
|
||||
// cache miss; result is memoised in the Moka cache for ~30s.
|
||||
let rows = sqlx::query(
|
||||
"WITH RECURSIVE user_groups AS (
|
||||
SELECT group_id
|
||||
FROM auth.subject_group_members
|
||||
WHERE member_user_id = $1
|
||||
UNION
|
||||
SELECT m.group_id
|
||||
FROM auth.subject_group_members m
|
||||
JOIN user_groups ug ON m.member_group_id = ug.group_id
|
||||
)
|
||||
SELECT group_id FROM user_groups",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("groups_for_user", e))?;
|
||||
|
||||
Ok(rows.iter().map(|r| r.get::<Uuid, _>("group_id")).collect())
|
||||
}
|
||||
}
|
||||
@@ -28,24 +28,49 @@
|
||||
//! DB transaction with the resource table need an explicit signal to
|
||||
//! delete their tuples.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
use moka::future::Cache;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::subject_group::INTERNAL_GROUP_ID;
|
||||
use crate::domain::repositories::subject_group_repository::SubjectGroupRepository;
|
||||
use crate::domain::services::authorization::{
|
||||
Grant, GrantCursor, IncomingGrantSummary, OutgoingGrantEntry, OutgoingResourceSummary,
|
||||
Permission, Resource, ResourceKind, Subject,
|
||||
};
|
||||
use crate::infrastructure::repositories::pg::SubjectGroupPgRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||
|
||||
/// Per-call counters surfaced through `tracing::debug!` for performance
|
||||
/// observability: cache hit-rate, SQL traffic, transitive expansion size.
|
||||
///
|
||||
/// Sub-microsecond cost when debug logging is off (one atomic write per
|
||||
/// increment, no allocation, no formatting).
|
||||
#[derive(Default)]
|
||||
struct QueryCounters {
|
||||
cache_hit: AtomicU32,
|
||||
sql_queries: AtomicU32,
|
||||
expanded_groups: AtomicU32,
|
||||
}
|
||||
|
||||
pub struct PgAclEngine {
|
||||
pool: Arc<PgPool>,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
file_repo: Arc<FileBlobReadRepository>,
|
||||
/// Group repository — `None` only in test stubs that don't exercise authz.
|
||||
group_repo: Option<Arc<SubjectGroupPgRepository>>,
|
||||
/// Memoise `user_id → transitive group set` for 30 s. Bounded to 50 000
|
||||
/// entries; eviction is LRU + TTL. Stale by up to TTL after a membership
|
||||
/// change — acceptable trade-off (see plan, "Cache TTL behaviour").
|
||||
user_groups_cache: Cache<Uuid, Arc<HashSet<Uuid>>>,
|
||||
}
|
||||
|
||||
impl PgAclEngine {
|
||||
@@ -53,11 +78,17 @@ impl PgAclEngine {
|
||||
pool: Arc<PgPool>,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
file_repo: Arc<FileBlobReadRepository>,
|
||||
group_repo: Arc<SubjectGroupPgRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
folder_repo,
|
||||
file_repo,
|
||||
group_repo: Some(group_repo),
|
||||
user_groups_cache: Cache::builder()
|
||||
.max_capacity(50_000)
|
||||
.time_to_live(Duration::from_secs(30))
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +106,79 @@ impl PgAclEngine {
|
||||
pool: Arc::new(pool),
|
||||
folder_repo: Arc::new(FolderDbRepository::new_stub()),
|
||||
file_repo: Arc::new(FileBlobReadRepository::new_stub()),
|
||||
group_repo: None,
|
||||
user_groups_cache: Cache::builder()
|
||||
.max_capacity(1)
|
||||
.time_to_live(Duration::from_secs(1))
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand a user subject into the set of subject UUIDs that should match
|
||||
/// in `access_grants`: the user's own UUID, every group the user is
|
||||
/// transitively a member of, and the implicit `INTERNAL_GROUP_ID`.
|
||||
///
|
||||
/// This is the **only** place transitive membership is walked. A future
|
||||
/// closure-table swap-in (Option 3 in the design doc) replaces just the
|
||||
/// `repo.groups_for_user` call below — every caller stays unchanged.
|
||||
async fn expand_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
counters: &QueryCounters,
|
||||
) -> Result<Arc<HashSet<Uuid>>, DomainError> {
|
||||
if let Some(cached) = self.user_groups_cache.get(&user_id).await {
|
||||
counters.cache_hit.store(1, Ordering::Relaxed);
|
||||
counters
|
||||
.expanded_groups
|
||||
.store(cached.len() as u32, Ordering::Relaxed);
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
let mut set: HashSet<Uuid> = HashSet::new();
|
||||
set.insert(user_id);
|
||||
// The Internal virtual group: implicit membership for every
|
||||
// authenticated user. Once the external-users work lands this will
|
||||
// narrow to `if !user.is_external { ... }`.
|
||||
set.insert(INTERNAL_GROUP_ID);
|
||||
|
||||
if let Some(repo) = &self.group_repo {
|
||||
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||
let direct = repo.groups_for_user(user_id).await.map_err(|e| {
|
||||
DomainError::internal_error("PgAcl", format!("groups_for_user: {e}"))
|
||||
})?;
|
||||
set.extend(direct);
|
||||
}
|
||||
|
||||
counters
|
||||
.expanded_groups
|
||||
.store(set.len() as u32, Ordering::Relaxed);
|
||||
let arc = Arc::new(set);
|
||||
self.user_groups_cache.insert(user_id, arc.clone()).await;
|
||||
Ok(arc)
|
||||
}
|
||||
|
||||
/// Expand a caller's `Subject` into the `(subject_types, subject_ids)`
|
||||
/// pair that should be matched in `storage.access_grants`. For User
|
||||
/// callers this is `(["user","group"], [uid, …transitive groups, INTERNAL])`;
|
||||
/// for any non-user subject (Token / External / Group as direct caller)
|
||||
/// it's a single-element pair with no cascade.
|
||||
///
|
||||
/// Shared by `check_inner` (permission decision) and the
|
||||
/// `list_incoming_*` queries ("Shared with me") so that any folder/file
|
||||
/// the user can `read` via a group grant also appears in their incoming
|
||||
/// listing. Shares the `expand_user` Moka cache, so the listing call
|
||||
/// right after a permission check is a cache hit.
|
||||
async fn subject_match_set(
|
||||
&self,
|
||||
subject: Subject,
|
||||
counters: &QueryCounters,
|
||||
) -> Result<(Vec<&'static str>, Vec<Uuid>), DomainError> {
|
||||
match subject {
|
||||
Subject::User(uid) => {
|
||||
let expanded = self.expand_user(uid, counters).await?;
|
||||
Ok((vec!["user", "group"], expanded.iter().copied().collect()))
|
||||
}
|
||||
_ => Ok((vec![subject.type_str()], vec![subject.id()])),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,21 +191,32 @@ impl PgAclEngine {
|
||||
}
|
||||
|
||||
/// Cascading check for folders: is there a grant on any ancestor folder
|
||||
/// (including the target itself) in this subject + permission?
|
||||
/// Uses GiST index on `storage.folders.lpath`.
|
||||
/// (including the target itself) for any of the given subject IDs and
|
||||
/// any of the given subject types?
|
||||
///
|
||||
/// `subject_types` is `["user", "group"]` when the caller is a User
|
||||
/// (so we match both their own grants and their group-mediated grants),
|
||||
/// or a single-element slice for Token / External / Group-direct callers.
|
||||
/// `subject_ids` is the expanded set returned by `expand_user` (or a
|
||||
/// single-element vec for non-user callers).
|
||||
///
|
||||
/// Uses the GiST index on `storage.folders.lpath` for O(log N) cascade.
|
||||
async fn folder_cascade_grant_exists(
|
||||
&self,
|
||||
subject: Subject,
|
||||
subject_types: &[&str],
|
||||
subject_ids: &[Uuid],
|
||||
permission: Permission,
|
||||
folder_id: Uuid,
|
||||
counters: &QueryCounters,
|
||||
) -> Result<bool, DomainError> {
|
||||
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||
let exists: Option<i32> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT 1
|
||||
FROM storage.access_grants g
|
||||
JOIN storage.folders gf ON gf.id = g.resource_id
|
||||
WHERE g.subject_type = $1
|
||||
AND g.subject_id = $2
|
||||
WHERE g.subject_type = ANY($1)
|
||||
AND g.subject_id = ANY($2)
|
||||
AND g.permission = $3
|
||||
AND g.resource_type = 'folder'
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
@@ -109,8 +224,8 @@ impl PgAclEngine {
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.bind(subject_types)
|
||||
.bind(subject_ids)
|
||||
.bind(permission.as_str())
|
||||
.bind(folder_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
@@ -121,13 +236,18 @@ impl PgAclEngine {
|
||||
}
|
||||
|
||||
/// Cascading check for files: either a direct file grant OR a grant on
|
||||
/// any ancestor folder of the file's containing folder.
|
||||
/// any ancestor folder of the file's containing folder. See
|
||||
/// `folder_cascade_grant_exists` for the meaning of `subject_types` /
|
||||
/// `subject_ids`.
|
||||
async fn file_cascade_grant_exists(
|
||||
&self,
|
||||
subject: Subject,
|
||||
subject_types: &[&str],
|
||||
subject_ids: &[Uuid],
|
||||
permission: Permission,
|
||||
file_id: Uuid,
|
||||
counters: &QueryCounters,
|
||||
) -> Result<bool, DomainError> {
|
||||
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||
let exists: Option<i32> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT 1
|
||||
@@ -135,7 +255,9 @@ impl PgAclEngine {
|
||||
-- direct file grant
|
||||
SELECT 1
|
||||
FROM storage.access_grants
|
||||
WHERE subject_type = $1 AND subject_id = $2 AND permission = $3
|
||||
WHERE subject_type = ANY($1)
|
||||
AND subject_id = ANY($2)
|
||||
AND permission = $3
|
||||
AND resource_type = 'file' AND resource_id = $4
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
UNION ALL
|
||||
@@ -144,8 +266,8 @@ impl PgAclEngine {
|
||||
FROM storage.access_grants g
|
||||
JOIN storage.folders gf ON gf.id = g.resource_id
|
||||
JOIN storage.files target_f ON target_f.id = $4
|
||||
WHERE g.subject_type = $1
|
||||
AND g.subject_id = $2
|
||||
WHERE g.subject_type = ANY($1)
|
||||
AND g.subject_id = ANY($2)
|
||||
AND g.permission = $3
|
||||
AND g.resource_type = 'folder'
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
@@ -156,8 +278,8 @@ impl PgAclEngine {
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.bind(subject_types)
|
||||
.bind(subject_ids)
|
||||
.bind(permission.as_str())
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
@@ -222,18 +344,20 @@ impl PgAclEngine {
|
||||
expires_at: row.8,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthorizationEngine for PgAclEngine {
|
||||
async fn check(
|
||||
/// The actual permission decision. Wrapped by `check()` which adds
|
||||
/// per-call instrumentation.
|
||||
async fn check_inner(
|
||||
&self,
|
||||
subject: Subject,
|
||||
permission: Permission,
|
||||
resource: Resource,
|
||||
counters: &QueryCounters,
|
||||
) -> Result<bool, DomainError> {
|
||||
// Owner short-circuit (only for User subjects — groups/tokens/external
|
||||
// are never owners of resources).
|
||||
if let Subject::User(uid) = subject {
|
||||
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||
match self.owner_of(resource).await {
|
||||
Ok(owner) if owner == uid => return Ok(true),
|
||||
Ok(_) => { /* not owner — fall through to grants */ }
|
||||
@@ -247,18 +371,67 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// Cascading grant check.
|
||||
// Expand the subject so group-mediated grants apply when the caller
|
||||
// is a User. See `subject_match_set` for the shared shape used by
|
||||
// both the cascade check and the "shared with me" listing queries.
|
||||
let (subject_types, subject_ids) = self.subject_match_set(subject, counters).await?;
|
||||
|
||||
match resource {
|
||||
Resource::Folder(id) => {
|
||||
self.folder_cascade_grant_exists(subject, permission, id)
|
||||
.await
|
||||
self.folder_cascade_grant_exists(
|
||||
&subject_types,
|
||||
&subject_ids,
|
||||
permission,
|
||||
id,
|
||||
counters,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Resource::File(id) => {
|
||||
self.file_cascade_grant_exists(subject, permission, id)
|
||||
.await
|
||||
self.file_cascade_grant_exists(
|
||||
&subject_types,
|
||||
&subject_ids,
|
||||
permission,
|
||||
id,
|
||||
counters,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthorizationEngine for PgAclEngine {
|
||||
async fn check(
|
||||
&self,
|
||||
subject: Subject,
|
||||
permission: Permission,
|
||||
resource: Resource,
|
||||
) -> Result<bool, DomainError> {
|
||||
let start = std::time::Instant::now();
|
||||
let counters = QueryCounters::default();
|
||||
|
||||
let result = self
|
||||
.check_inner(subject, permission, resource, &counters)
|
||||
.await;
|
||||
|
||||
// Single structured debug line per check. No-op when subscriber
|
||||
// filter is at INFO or above. See plan, "Debug instrumentation".
|
||||
tracing::debug!(
|
||||
target: "oxicloud::authz",
|
||||
event = "authz.check",
|
||||
subject = %subject,
|
||||
permission = %permission,
|
||||
resource = %resource,
|
||||
allowed = result.as_ref().copied().unwrap_or(false),
|
||||
duration_us = start.elapsed().as_micros() as u64,
|
||||
cache_hit = counters.cache_hit.load(Ordering::Relaxed) > 0,
|
||||
sql_queries = counters.sql_queries.load(Ordering::Relaxed),
|
||||
expanded_groups = counters.expanded_groups.load(Ordering::Relaxed),
|
||||
);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn list_incoming_grants(
|
||||
&self,
|
||||
@@ -266,6 +439,8 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
permission_filter: Option<Permission>,
|
||||
) -> Result<Vec<Grant>, DomainError> {
|
||||
let perm_str = permission_filter.map(|p| p.as_str().to_string());
|
||||
let counters = QueryCounters::default();
|
||||
let (subject_types, subject_ids) = self.subject_match_set(subject, &counters).await?;
|
||||
|
||||
let rows = sqlx::query_as::<
|
||||
_,
|
||||
@@ -285,14 +460,14 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
SELECT id, subject_type, subject_id, resource_type, resource_id,
|
||||
permission, granted_by, granted_at, expires_at
|
||||
FROM storage.access_grants
|
||||
WHERE subject_type = $1
|
||||
AND subject_id = $2
|
||||
WHERE subject_type = ANY($1)
|
||||
AND subject_id = ANY($2)
|
||||
AND ($3::text IS NULL OR permission = $3)
|
||||
ORDER BY granted_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.bind(&subject_types)
|
||||
.bind(&subject_ids)
|
||||
.bind(perm_str)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
@@ -350,6 +525,10 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
let cursor_id = cursor.as_ref().map(|c| c.resource_id);
|
||||
|
||||
// ── agg CTE (identical in all branches) ───────────────────────────────
|
||||
// `subject_type`/`subject_id` are arrays here: for a User caller this
|
||||
// is `(["user","group"], [uid, …transitive groups, INTERNAL])` so the
|
||||
// listing includes every resource the user can reach via a group
|
||||
// grant (matching what `check()` allows). See `subject_match_set`.
|
||||
const AGG: &str = r#"agg AS (
|
||||
SELECT
|
||||
resource_type,
|
||||
@@ -358,8 +537,8 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
MIN(granted_at) AS granted_at,
|
||||
(array_agg(granted_by ORDER BY granted_at))[1] AS granted_by
|
||||
FROM storage.access_grants
|
||||
WHERE subject_type = $1
|
||||
AND subject_id = $2
|
||||
WHERE subject_type = ANY($1)
|
||||
AND subject_id = ANY($2)
|
||||
AND ($3::text[] IS NULL OR resource_type = ANY($3))
|
||||
GROUP BY resource_type, resource_id
|
||||
)"#;
|
||||
@@ -501,10 +680,15 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
}
|
||||
};
|
||||
|
||||
// Expand the caller so group-mediated grants surface in the listing,
|
||||
// mirroring `check()`. Shares the Moka cache (`expand_user`).
|
||||
let counters = QueryCounters::default();
|
||||
let (subject_types, subject_ids) = self.subject_match_set(subject, &counters).await?;
|
||||
|
||||
// ── Execute — uniform 8 binds for every sort mode ─────────────────────
|
||||
let mut rows: Vec<Row> = sqlx::query_as::<_, Row>(&sql)
|
||||
.bind(subject.type_str()) // $1
|
||||
.bind(subject.id()) // $2
|
||||
.bind(&subject_types) // $1
|
||||
.bind(&subject_ids) // $2
|
||||
.bind(&kind_strs) // $3
|
||||
.bind(&cursor_str) // $4 sort_str cursor
|
||||
.bind(cursor_int) // $5 sort_int cursor
|
||||
@@ -740,7 +924,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
)
|
||||
SELECT ag.resource_type, ag.resource_id, rp.first_shared_at,
|
||||
ag.subject_type, ag.subject_id,
|
||||
COALESCE(u.username, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
||||
COALESCE(u.username, sg.name::text, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
||||
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
|
||||
rp.sort_str, rp.sort_int,
|
||||
(sh.password_hash IS NOT NULL) AS has_password
|
||||
@@ -749,17 +933,34 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id
|
||||
AND ag.granted_by = $1
|
||||
LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id
|
||||
LEFT JOIN auth.subject_groups sg ON ag.subject_type = 'group' AND sg.id = ag.subject_id
|
||||
LEFT JOIN storage.shares sh ON ag.subject_type = 'token' AND sh.id = ag.subject_id
|
||||
LEFT JOIN storage.files fi ON ag.subject_type = 'token' AND ag.resource_type = 'file' AND fi.id = ag.resource_id
|
||||
LEFT JOIN storage.folders fld ON ag.subject_type = 'token' AND ag.resource_type = 'folder' AND fld.id = ag.resource_id
|
||||
ORDER BY {page_order}, ag.subject_id, ag.granted_at"#
|
||||
-- Per-resource grant ordering: groups → users → password-protected
|
||||
-- links → public links (matches the "Shared with" subject sort).
|
||||
-- Resource ordering comes from {page_order}; the CASE only
|
||||
-- breaks ties within one resource.
|
||||
ORDER BY {page_order},
|
||||
CASE
|
||||
WHEN ag.subject_type = 'group' THEN 0
|
||||
WHEN ag.subject_type = 'user' THEN 1
|
||||
WHEN ag.subject_type = 'token' AND sh.password_hash IS NOT NULL THEN 2
|
||||
ELSE 3
|
||||
END ASC,
|
||||
LOWER(COALESCE(u.username, sg.name::text, sh.item_name, ag.subject_id::text)) ASC,
|
||||
ag.granted_at"#
|
||||
)
|
||||
}
|
||||
"subject" => {
|
||||
// Page on (subject_type_order, subject_display, resource_id) triples so
|
||||
// every swimlane is always contiguous across cursor pages.
|
||||
//
|
||||
// subject_type_order: 0 = user, 1 = token without password, 2 = token with password
|
||||
// subject_type_order: 0 = group, 1 = user, 2 = token with password,
|
||||
// 3 = token without password
|
||||
// — picked so the My Shares "Shared with" view naturally renders the
|
||||
// higher-trust principals (groups, then named users) above the
|
||||
// lower-trust ones (anonymous link tokens).
|
||||
//
|
||||
// Cursor encodes: sort_int = subject_type_order, resource_name = LOWER(subject_display),
|
||||
// resource_id = last resource_id.
|
||||
@@ -787,17 +988,20 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ag.resource_id,
|
||||
ag.subject_type,
|
||||
ag.subject_id,
|
||||
MAX(COALESCE(u.username, sh.item_name, ag.subject_id::text)) AS subject_display,
|
||||
MAX(COALESCE(u.username, sg.name::text, sh.item_name, ag.subject_id::text)) AS subject_display,
|
||||
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
|
||||
MAX(CASE
|
||||
WHEN ag.subject_type = 'user' THEN 0
|
||||
WHEN ag.subject_type = 'token' AND sh.password_hash IS NULL THEN 1
|
||||
ELSE 2
|
||||
WHEN ag.subject_type = 'group' THEN 0
|
||||
WHEN ag.subject_type = 'user' THEN 1
|
||||
WHEN ag.subject_type = 'token' AND sh.password_hash IS NOT NULL THEN 2
|
||||
ELSE 3
|
||||
END)::bigint AS sort_int,
|
||||
MIN(ag.granted_at) AS first_granted_at
|
||||
FROM storage.access_grants ag
|
||||
LEFT JOIN auth.users u
|
||||
ON ag.subject_type = 'user' AND u.id = ag.subject_id
|
||||
LEFT JOIN auth.subject_groups sg
|
||||
ON ag.subject_type = 'group' AND sg.id = ag.subject_id
|
||||
LEFT JOIN storage.shares sh
|
||||
ON ag.subject_type = 'token' AND sh.id = ag.subject_id
|
||||
LEFT JOIN storage.files fi
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Json, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::{delete, get, post, put},
|
||||
};
|
||||
@@ -12,9 +12,9 @@ use crate::application::dtos::settings_dto::{
|
||||
TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto,
|
||||
UpdateUserRoleDto, VerifyMigrationDto,
|
||||
};
|
||||
use crate::application::ports::auth_ports::TokenServicePort;
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::admin::require_admin;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -61,42 +61,12 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
}
|
||||
|
||||
/// Validate JWT and require admin role. Returns (user_id, role).
|
||||
///
|
||||
/// Thin wrapper over the shared `require_admin` middleware helper so this
|
||||
/// handler keeps a stable signature while the implementation lives next to
|
||||
/// the new `subject_group_handler` that also needs it.
|
||||
async fn admin_guard(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(|| {
|
||||
crate::interfaces::api::cookie_auth::extract_cookie_value(
|
||||
headers,
|
||||
crate::interfaces::api::cookie_auth::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,
|
||||
))
|
||||
require_admin(state, headers).await
|
||||
}
|
||||
|
||||
/// GET /api/admin/settings/oidc — get OIDC settings for the admin panel
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod photos_handler;
|
||||
pub mod recent_handler;
|
||||
pub mod search_handler;
|
||||
pub mod share_handler;
|
||||
pub mod subject_group_handler;
|
||||
pub mod trash_handler;
|
||||
pub mod webdav_handler;
|
||||
pub mod wopi_handler;
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
//! REST handler for ReBAC subject groups (`/api/groups/...`).
|
||||
//!
|
||||
//! All mutating endpoints require admin role (see
|
||||
//! `crate::interfaces::middleware::admin::require_admin`). The read-only
|
||||
//! `/api/groups/search` endpoint requires only authentication so the share
|
||||
//! dialog can offer groups as recipients.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Json, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::{delete, get, patch, post},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::di::AppState;
|
||||
use crate::domain::entities::subject_group::{GroupMember, SubjectGroup};
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::admin::{require_admin, require_authenticated};
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateGroupRequest {
|
||||
/// RFC 5321 local-part shape (starts alnum, then alnum/dot/dash/underscore; 1–64 chars).
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateGroupRequest {
|
||||
pub name: Option<String>,
|
||||
/// Reserved for future use; v1 only persists the name in `rename`.
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct ListGroupsQuery {
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: u32,
|
||||
#[serde(default)]
|
||||
pub offset: u32,
|
||||
/// Optional case-insensitive substring filter on group name.
|
||||
pub q: Option<String>,
|
||||
}
|
||||
|
||||
fn default_limit() -> u32 {
|
||||
50
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct SearchGroupsQuery {
|
||||
pub q: String,
|
||||
#[serde(default = "default_search_limit")]
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
fn default_search_limit() -> u32 {
|
||||
20
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct AddSubjectGroupMemberRequest {
|
||||
/// Set exactly one of `user_id` or `group_id` — the other field must be
|
||||
/// absent or null. Adding a `group_id` triggers a write-time cycle and
|
||||
/// depth check; max nesting depth is 8.
|
||||
pub user_id: Option<Uuid>,
|
||||
pub group_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct GroupDto {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// True for system-managed groups (e.g. `Internal`). Membership and
|
||||
/// metadata on virtual groups are immutable.
|
||||
pub is_virtual: bool,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
/// True when the caller may rename, delete, or change the membership
|
||||
/// of this group. v1 computes this as `caller.role == "admin"`. v2
|
||||
/// will compute it from per-group `Manage` grants on
|
||||
/// `Resource::SubjectGroup(id)` once that resource type lands in
|
||||
/// `access_grants`. Frontend reads this unconditionally so the v2
|
||||
/// migration is backend-only.
|
||||
pub can_manage: bool,
|
||||
/// Direct-member count (users + nested groups, one level only). The
|
||||
/// management UI shows this as a chip on each list row; for transitive
|
||||
/// expansion size, see the `/effective-members` endpoint.
|
||||
pub member_count: i64,
|
||||
}
|
||||
|
||||
impl GroupDto {
|
||||
/// Build the DTO for a given caller. `can_manage` is decided per call:
|
||||
/// v1 just delegates to the admin flag; v2 will consult the
|
||||
/// authorization engine here. `member_count` comes from the same query
|
||||
/// that fetched the group (list/search) or a dedicated `COUNT(*)`
|
||||
/// helper (create/get/update).
|
||||
pub fn from_group(g: SubjectGroup, can_manage: bool, member_count: i64) -> Self {
|
||||
Self {
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
is_virtual: g.is_virtual,
|
||||
created_at: g.created_at,
|
||||
updated_at: g.updated_at,
|
||||
can_manage,
|
||||
member_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct GroupListDto {
|
||||
pub items: Vec<GroupDto>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum GroupMemberDto {
|
||||
User { id: Uuid },
|
||||
Group { id: Uuid },
|
||||
}
|
||||
|
||||
impl From<GroupMember> for GroupMemberDto {
|
||||
fn from(m: GroupMember) -> Self {
|
||||
match m {
|
||||
GroupMember::User(id) => GroupMemberDto::User { id },
|
||||
GroupMember::Group(id) => GroupMemberDto::Group { id },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Routes mounted under `/api/groups`.
|
||||
pub fn subject_group_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/", post(create_group))
|
||||
.route("/", get(list_groups))
|
||||
.route("/search", get(search_groups))
|
||||
.route("/{id}", get(get_group))
|
||||
.route("/{id}", patch(update_group))
|
||||
.route("/{id}", delete(delete_group))
|
||||
.route("/{id}/members", get(list_members))
|
||||
.route("/{id}/members", post(add_member))
|
||||
.route("/{id}/members/user/{uid}", delete(remove_user_member))
|
||||
.route("/{id}/members/group/{gid}", delete(remove_group_member))
|
||||
.route("/{id}/effective-members", get(list_effective_members))
|
||||
}
|
||||
|
||||
fn service(
|
||||
state: &AppState,
|
||||
) -> Result<&Arc<crate::application::services::subject_group_service::SubjectGroupService>, AppError>
|
||||
{
|
||||
state
|
||||
.subject_group_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Subject-group service not configured"))
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Create a new ReBAC subject group. Admin-only. The name must match the
|
||||
/// RFC 5321 local-part shape and be globally unique (case-insensitive).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/groups",
|
||||
request_body = CreateGroupRequest,
|
||||
responses(
|
||||
(status = 201, description = "Group created", body = GroupDto),
|
||||
(status = 400, description = "Invalid name (RFC 5321 violation)"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required"),
|
||||
(status = 409, description = "Group with this name already exists"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "groups",
|
||||
operation_id = "subject_group_create"
|
||||
)]
|
||||
pub async fn create_group(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreateGroupRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (caller_id, _) = require_admin(&state, &headers).await?;
|
||||
let svc = service(&state)?;
|
||||
let group = svc
|
||||
.create(&req.name, req.description, caller_id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
// Reached require_admin → caller is admin → can_manage is always true here.
|
||||
// New groups start with zero direct members.
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(GroupDto::from_group(group, true, 0)),
|
||||
))
|
||||
}
|
||||
|
||||
/// List subject groups (paginated). Admin-only.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/groups",
|
||||
params(ListGroupsQuery),
|
||||
responses(
|
||||
(status = 200, description = "Paginated list of groups", body = GroupListDto),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "groups",
|
||||
operation_id = "subject_group_list"
|
||||
)]
|
||||
pub async fn list_groups(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListGroupsQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
require_admin(&state, &headers).await?;
|
||||
let svc = service(&state)?;
|
||||
let (items, total) = svc
|
||||
.list_with_counts(q.limit, q.offset, q.q.as_deref())
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
// Admin-gated handler → every row is manageable by the caller.
|
||||
Ok(Json(GroupListDto {
|
||||
items: items
|
||||
.into_iter()
|
||||
.map(|(g, member_count)| GroupDto::from_group(g, true, member_count))
|
||||
.collect(),
|
||||
total,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Search non-virtual groups by name substring. Authenticated only (no
|
||||
/// admin role required) — backs the share-dialog recipient autocomplete.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/groups/search",
|
||||
params(SearchGroupsQuery),
|
||||
responses(
|
||||
(status = 200, description = "Matching non-virtual groups", body = [GroupDto]),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "groups",
|
||||
operation_id = "subject_group_search"
|
||||
)]
|
||||
pub async fn search_groups(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<SearchGroupsQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// Any authenticated user can discover groups for the share dialog —
|
||||
// membership lists remain admin-only via list_members.
|
||||
let (_caller_id, role) = require_authenticated(&state, &headers).await?;
|
||||
let can_manage = role == "admin";
|
||||
let svc = service(&state)?;
|
||||
// The share-dialog autocomplete doesn't render a member-count chip, so
|
||||
// emit 0 rather than spending a `COUNT(*)` per row. Frontend consumers
|
||||
// that need the real count fetch it via `/api/groups/{id}` instead.
|
||||
let items = svc
|
||||
.search_for_share(&q.q, q.limit)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(
|
||||
items
|
||||
.into_iter()
|
||||
.map(|g| GroupDto::from_group(g, can_manage, 0))
|
||||
.collect::<Vec<_>>(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Fetch a single group's details. Admin-only.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/groups/{id}",
|
||||
params(("id" = Uuid, Path, description = "Group ID")),
|
||||
responses(
|
||||
(status = 200, description = "Group details", body = GroupDto),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required"),
|
||||
(status = 404, description = "Group not found"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "groups",
|
||||
operation_id = "subject_group_get"
|
||||
)]
|
||||
pub async fn get_group(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
require_admin(&state, &headers).await?;
|
||||
let svc = service(&state)?;
|
||||
let group = svc.get_by_id(id).await.map_err(AppError::from)?;
|
||||
let member_count = svc.count_members(id).await.map_err(AppError::from)?;
|
||||
Ok(Json(GroupDto::from_group(group, true, member_count)))
|
||||
}
|
||||
|
||||
/// Update a group's metadata. Admin-only. v1 only persists name renames.
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/groups/{id}",
|
||||
params(("id" = Uuid, Path, description = "Group ID")),
|
||||
request_body = UpdateGroupRequest,
|
||||
responses(
|
||||
(status = 200, description = "Updated group", body = GroupDto),
|
||||
(status = 400, description = "Invalid name"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required, or group is virtual"),
|
||||
(status = 404, description = "Group not found"),
|
||||
(status = 409, description = "Name already taken"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "groups",
|
||||
operation_id = "subject_group_update"
|
||||
)]
|
||||
pub async fn update_group(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateGroupRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (caller_id, _) = require_admin(&state, &headers).await?;
|
||||
let svc = service(&state)?;
|
||||
|
||||
// v1 only supports renaming; description-only updates are silently
|
||||
// accepted as a no-op so the API surface is forward-compatible.
|
||||
let group = match req.name {
|
||||
Some(new_name) => svc
|
||||
.rename(id, &new_name, caller_id)
|
||||
.await
|
||||
.map_err(AppError::from)?,
|
||||
None => svc.get_by_id(id).await.map_err(AppError::from)?,
|
||||
};
|
||||
let member_count = svc.count_members(id).await.map_err(AppError::from)?;
|
||||
Ok(Json(GroupDto::from_group(group, true, member_count)))
|
||||
}
|
||||
|
||||
/// Delete a group. Cascades to `subject_group_members` (FK) and to
|
||||
/// `access_grants` rows referencing this group as a subject. Admin-only.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/groups/{id}",
|
||||
params(("id" = Uuid, Path, description = "Group ID")),
|
||||
responses(
|
||||
(status = 204, description = "Group deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required, or group is virtual"),
|
||||
(status = 404, description = "Group not found"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "groups",
|
||||
operation_id = "subject_group_delete"
|
||||
)]
|
||||
pub async fn delete_group(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (caller_id, _) = require_admin(&state, &headers).await?;
|
||||
let svc = service(&state)?;
|
||||
svc.delete(id, caller_id).await.map_err(AppError::from)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// List the *direct* members of a group (one level only). Admin-only.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/groups/{id}/members",
|
||||
params(("id" = Uuid, Path, description = "Group ID")),
|
||||
responses(
|
||||
(status = 200, description = "Direct members", body = [GroupMemberDto]),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required"),
|
||||
(status = 404, description = "Group not found"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "groups",
|
||||
operation_id = "subject_group_list_members"
|
||||
)]
|
||||
pub async fn list_members(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
require_admin(&state, &headers).await?;
|
||||
let svc = service(&state)?;
|
||||
let members = svc.list_direct_members(id).await.map_err(AppError::from)?;
|
||||
Ok(Json(
|
||||
members
|
||||
.into_iter()
|
||||
.map(GroupMemberDto::from)
|
||||
.collect::<Vec<_>>(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/groups/{id}/members",
|
||||
params(("id" = Uuid, Path, description = "Group ID")),
|
||||
request_body = AddSubjectGroupMemberRequest,
|
||||
responses(
|
||||
(status = 201, description = "Member added"),
|
||||
(status = 400, description = "Invalid request, cycle would be created, or depth limit exceeded"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required, or virtual group"),
|
||||
(status = 404, description = "Group not found"),
|
||||
(status = 409, description = "Member already in group"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "groups",
|
||||
operation_id = "subject_group_add_member"
|
||||
)]
|
||||
pub async fn add_member(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(group_id): Path<Uuid>,
|
||||
Json(req): Json<AddSubjectGroupMemberRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (caller_id, _) = require_admin(&state, &headers).await?;
|
||||
let svc = service(&state)?;
|
||||
|
||||
let member = match (req.user_id, req.group_id) {
|
||||
(Some(uid), None) => GroupMember::User(uid),
|
||||
(None, Some(gid)) => GroupMember::Group(gid),
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(AppError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Provide exactly one of user_id or group_id, not both",
|
||||
"InvalidInput",
|
||||
));
|
||||
}
|
||||
(None, None) => {
|
||||
return Err(AppError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Provide user_id or group_id",
|
||||
"InvalidInput",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
svc.add_member(group_id, member, caller_id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
/// Remove a user-member from a group. Admin-only.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/groups/{id}/members/user/{uid}",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Group ID"),
|
||||
("uid" = Uuid, Path, description = "User ID to remove"),
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Member removed"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required, or virtual group"),
|
||||
(status = 404, description = "Group or member not found"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "groups",
|
||||
operation_id = "subject_group_remove_user_member"
|
||||
)]
|
||||
pub async fn remove_user_member(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path((group_id, uid)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (caller_id, _) = require_admin(&state, &headers).await?;
|
||||
let svc = service(&state)?;
|
||||
svc.remove_member(group_id, GroupMember::User(uid), caller_id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Remove a nested group-member from a group. Admin-only.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/groups/{id}/members/group/{gid}",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Parent group ID"),
|
||||
("gid" = Uuid, Path, description = "Child group ID to remove"),
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Member removed"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required, or virtual group"),
|
||||
(status = 404, description = "Group or member not found"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "groups",
|
||||
operation_id = "subject_group_remove_group_member"
|
||||
)]
|
||||
pub async fn remove_group_member(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path((group_id, gid)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (caller_id, _) = require_admin(&state, &headers).await?;
|
||||
let svc = service(&state)?;
|
||||
svc.remove_member(group_id, GroupMember::Group(gid), caller_id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// List every user transitively reached through this group (members of
|
||||
/// members of members, etc.). Used by admin / audit tooling. Admin-only.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/groups/{id}/effective-members",
|
||||
params(("id" = Uuid, Path, description = "Group ID")),
|
||||
responses(
|
||||
(status = 200, description = "Flat list of transitively-reached user IDs", body = [Uuid]),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required"),
|
||||
(status = 404, description = "Group not found"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "groups",
|
||||
operation_id = "subject_group_effective_members"
|
||||
)]
|
||||
pub async fn list_effective_members(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
require_admin(&state, &headers).await?;
|
||||
let svc = service(&state)?;
|
||||
let users = svc
|
||||
.list_transitive_users(id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(users))
|
||||
}
|
||||
@@ -230,6 +230,18 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
handlers::grant_handler::list_shared_with_me,
|
||||
handlers::grant_handler::list_outgoing,
|
||||
handlers::grant_handler::list_on_resource,
|
||||
// Subject-group handlers (ReBAC named groups) — free functions
|
||||
handlers::subject_group_handler::create_group,
|
||||
handlers::subject_group_handler::list_groups,
|
||||
handlers::subject_group_handler::search_groups,
|
||||
handlers::subject_group_handler::get_group,
|
||||
handlers::subject_group_handler::update_group,
|
||||
handlers::subject_group_handler::delete_group,
|
||||
handlers::subject_group_handler::list_members,
|
||||
handlers::subject_group_handler::add_member,
|
||||
handlers::subject_group_handler::remove_user_member,
|
||||
handlers::subject_group_handler::remove_group_member,
|
||||
handlers::subject_group_handler::list_effective_members,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
@@ -319,6 +331,13 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
GrantDto,
|
||||
SharedWithMeDto,
|
||||
SharedWithMeItemDto,
|
||||
// Subject-group (ReBAC named groups) schemas
|
||||
handlers::subject_group_handler::CreateGroupRequest,
|
||||
handlers::subject_group_handler::UpdateGroupRequest,
|
||||
handlers::subject_group_handler::AddSubjectGroupMemberRequest,
|
||||
handlers::subject_group_handler::GroupDto,
|
||||
handlers::subject_group_handler::GroupListDto,
|
||||
handlers::subject_group_handler::GroupMemberDto,
|
||||
)
|
||||
),
|
||||
tags(
|
||||
@@ -339,6 +358,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
(name = "contacts", description = "Address books, contacts, and groups endpoints"),
|
||||
(name = "admin", description = "Admin management endpoints"),
|
||||
(name = "grants", description = "ReBAC grant management endpoints"),
|
||||
(name = "groups", description = "ReBAC subject-group management endpoints (named, nestable, root-owned)"),
|
||||
),
|
||||
info(
|
||||
title = "OxiCloud API",
|
||||
|
||||
@@ -563,6 +563,14 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
let admin_router = admin_handler::admin_routes().with_state(app_state.clone());
|
||||
router = router.nest("/admin", admin_router);
|
||||
|
||||
// ReBAC subject-group management. All mutating routes are admin-gated;
|
||||
// /api/groups/search is authenticated-only so the share dialog can list
|
||||
// groups as recipients.
|
||||
let group_router =
|
||||
crate::interfaces::api::handlers::subject_group_handler::subject_group_routes()
|
||||
.with_state(app_state.clone());
|
||||
router = router.nest("/groups", group_router);
|
||||
|
||||
// Transparent compression (gzip + brotli) for all API responses.
|
||||
// tower-http negotiates via Accept-Encoding and skips already-compressed
|
||||
// content types automatically. No manual compression in handlers.
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod csrf;
|
||||
pub mod rate_limit;
|
||||
|
||||
Reference in New Issue
Block a user