diff --git a/CLAUDE.md b/CLAUDE.md index 244e4fcb8..00742bf7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -230,7 +230,7 @@ cargo test -- --nocapture ```bash # Format code cargo fmt -cd web && npm run format +cd web && npx biome check --write . # Lint code cargo clippy -- -D warnings diff --git a/crates/codex-api/src/docs.rs b/crates/codex-api/src/docs.rs index 7aae46b06..59bb7289a 100644 --- a/crates/codex-api/src/docs.rs +++ b/crates/codex-api/src/docs.rs @@ -599,13 +599,17 @@ The following paths are exempt from rate limiting: v1::handlers::access_groups::update_access_group, v1::handlers::access_groups::delete_access_group, v1::handlers::access_groups::add_access_group_members, + v1::handlers::access_groups::add_all_users_to_access_group, v1::handlers::access_groups::remove_access_group_member, v1::handlers::access_groups::get_user_access_groups, + v1::handlers::access_groups::get_access_group_rules, + v1::handlers::access_groups::replace_access_group_rules, v1::handlers::access_groups::add_access_group_grant, v1::handlers::access_groups::remove_access_group_grant, v1::handlers::access_groups::add_access_group_oidc_mapping, v1::handlers::access_groups::remove_access_group_oidc_mapping, v1::handlers::access_groups::get_user_effective_grants, + v1::handlers::access_groups::get_visibility_preview, // Sharing Tags endpoints v1::handlers::sharing_tags::list_sharing_tags, @@ -962,6 +966,21 @@ The following paths are exempt from rate limiting: v1::dto::AccessGroupSummaryDto, v1::dto::CreateAccessGroupRequest, v1::dto::UpdateAccessGroupRequest, + v1::dto::AddAllUsersResponse, + v1::dto::AccessGroupRulesDto, + v1::dto::VisibilityPreviewDto, + v1::dto::LibraryVisibilityDto, + v1::dto::SeriesAccessDto, + v1::dto::MatchedRuleDto, + codex_services::content_filter::AccessDecision, + codex_services::content_filter::RuleTypeLabel, + v1::dto::SharingTagRuleDto, + v1::dto::NameRuleDto, + v1::dto::LibraryRuleDto, + v1::dto::ReplaceAccessGroupRulesRequest, + v1::dto::SharingTagRuleInput, + v1::dto::LibraryRuleInput, + codex_db::entities::access_groups::RuleMatch, v1::dto::AddAccessGroupMembersRequest, v1::dto::AddAccessGroupGrantRequest, v1::dto::AddAccessGroupOidcMappingRequest, @@ -1008,6 +1027,7 @@ The following paths are exempt from rate limiting: v1::dto::RetryErrorsResponse, v1::dto::PageDto, v1::dto::UserDto, + v1::dto::UserAccessGroupDto, v1::dto::UserDetailDto, v1::dto::CreateUserRequest, v1::dto::UpdateUserRequest, diff --git a/crates/codex-api/src/lib.rs b/crates/codex-api/src/lib.rs index 93a43504f..4cbc19129 100644 --- a/crates/codex-api/src/lib.rs +++ b/crates/codex-api/src/lib.rs @@ -9,6 +9,7 @@ pub mod page_extract; pub mod permissions; pub mod ranged_file; pub mod routes; +pub mod visibility; pub mod web; #[allow(unused_imports)] diff --git a/crates/codex-api/src/routes/komga/handlers/books.rs b/crates/codex-api/src/routes/komga/handlers/books.rs index fde1aab14..ec9001c72 100644 --- a/crates/codex-api/src/routes/komga/handlers/books.rs +++ b/crates/codex-api/src/routes/komga/handlers/books.rs @@ -127,6 +127,7 @@ pub async fn get_book( Path(book_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; let user_id = Some(auth.user_id); @@ -207,6 +208,7 @@ pub async fn get_book_thumbnail( Path(book_id): Path, ) -> Result { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Serve from the on-disk thumbnail cache without touching the database. // A Komga client painting a shelf asks for every cover at once, and the @@ -342,7 +344,7 @@ pub async fn get_books_ondeck( let size = query.size.clamp(1, 500) as u64; let offset = page * size; - let content_filter = ContentFilter::for_user(&state.db, user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -510,6 +512,7 @@ pub async fn search_books( .or(body.search_term.as_deref().filter(|s| !s.is_empty())); // Use composable query with database-level sorting + let visibility = crate::visibility::user_visibility(&state.db, user_id).await?; let options = BookQueryOptions { library_id, series_id, @@ -521,6 +524,7 @@ pub async fn search_books( sort, page, page_size: size, + visibility: visibility.as_ref(), }; let (books, total) = BookRepository::query(&state.db, options) @@ -609,7 +613,7 @@ pub async fn get_next_book( let user_id = Some(auth.user_id); - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -701,7 +705,7 @@ pub async fn get_previous_book( let user_id = Some(auth.user_id); - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -787,6 +791,7 @@ pub async fn download_book_file( Path(book_id): Path, ) -> Result { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Fetch book from database let book = BookRepository::get_by_id(&state.db, book_id) diff --git a/crates/codex-api/src/routes/komga/handlers/collections.rs b/crates/codex-api/src/routes/komga/handlers/collections.rs index cbfe3b8c5..70071cc8b 100644 --- a/crates/codex-api/src/routes/komga/handlers/collections.rs +++ b/crates/codex-api/src/routes/komga/handlers/collections.rs @@ -15,9 +15,10 @@ use super::super::dto::series::KomgaSeriesDto; use super::super::dto::stubs::{KomgaCollectionDto, StubPaginationQuery}; use super::series::{build_series_dto, generate_series_thumbnail}; use crate::require_permission; +use crate::visibility::user_visibility; use crate::{ error::ApiError, - extractors::{AuthState, ContentFilter, FlexibleAuthContext}, + extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; use axum::{ @@ -97,16 +98,6 @@ fn parse_komga_collection_sort(sort: Option<&str>) -> Option { Some(SeriesSortParam { field, direction }) } -async fn user_visibility( - state: &AuthState, - user_id: Uuid, -) -> Result, ApiError> { - let filter = ContentFilter::for_user(&state.db, user_id) - .await - .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {e}")))?; - Ok(filter.to_visibility()) -} - /// Created/modified dates for a virtual queue-backed DTO: first and last /// `added_at` of the user's queue entries, or "now" for an empty queue. pub(crate) fn queue_dates(entries: &[codex_db::entities::want_to_read::Model]) -> (String, String) { @@ -190,7 +181,7 @@ pub async fn list_collections( Query(query): Query, ) -> Result>, ApiError> { require_permission!(auth, Permission::SeriesRead)?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; let collections = CollectionRepository::list_all(&state.db) .await @@ -234,7 +225,7 @@ pub async fn get_collection( Path(collection_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesRead)?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; if collection_id == WANT_TO_READ_ID { return Ok(Json( build_want_to_read_dto(&state, auth.user_id, vis.as_ref()).await?, @@ -270,7 +261,7 @@ pub async fn get_collection_series( Query(query): Query, ) -> Result>, ApiError> { require_permission!(auth, Permission::SeriesRead)?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; let members = if collection_id == WANT_TO_READ_ID { WantToReadRepository::queued_series(&state.db, auth.user_id, vis.as_ref()) .await @@ -353,7 +344,7 @@ pub async fn get_collection_thumbnail( Path(collection_id): Path, ) -> Result { auth.require_permission(&Permission::SeriesRead)?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; let members = if collection_id == WANT_TO_READ_ID { WantToReadRepository::queued_series(&state.db, auth.user_id, vis.as_ref()) .await @@ -475,7 +466,8 @@ pub async fn get_series_collections( require_permission!(auth, Permission::SeriesRead)?; let sid = Uuid::parse_str(&series_id) .map_err(|_| ApiError::NotFound("Series not found".to_string()))?; - let vis = user_visibility(&state, auth.user_id).await?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, sid).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; let collections = CollectionRepository::get_collections_for_series(&state.db, sid) .await diff --git a/crates/codex-api/src/routes/komga/handlers/libraries.rs b/crates/codex-api/src/routes/komga/handlers/libraries.rs index 992ac32d5..e3b5e9878 100644 --- a/crates/codex-api/src/routes/komga/handlers/libraries.rs +++ b/crates/codex-api/src/routes/komga/handlers/libraries.rs @@ -61,6 +61,11 @@ pub async fn list_libraries( let libraries = LibraryRepository::list_all(&state.db) .await .map_err(|e| ApiError::Internal(format!("Failed to fetch libraries: {}", e)))?; + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; + let libraries: Vec<_> = libraries + .into_iter() + .filter(|library| filter.is_library_visible(library.id)) + .collect(); let dtos: Vec = libraries .into_iter() @@ -113,6 +118,7 @@ pub async fn get_library( Path(library_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::LibrariesRead)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; let library = LibraryRepository::get_by_id(&state.db, library_id) .await @@ -166,6 +172,7 @@ pub async fn get_library_thumbnail( Path(library_id): Path, ) -> Result { require_permission!(auth, Permission::LibrariesRead)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; // Verify library exists let _library = LibraryRepository::get_by_id(&state.db, library_id) @@ -174,9 +181,15 @@ pub async fn get_library_thumbnail( .ok_or_else(|| ApiError::NotFound("Library not found".to_string()))?; // Get the first series in this library to use as the thumbnail - let series_list = SeriesRepository::list_by_library(&state.db, library_id, None) - .await - .map_err(|e| ApiError::Internal(format!("Failed to fetch series: {}", e)))?; + let series_list = SeriesRepository::list_by_library( + &state.db, + library_id, + crate::visibility::user_visibility(&state.db, auth.user_id) + .await? + .as_ref(), + ) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch series: {}", e)))?; let first_series = series_list .into_iter() diff --git a/crates/codex-api/src/routes/komga/handlers/manifest.rs b/crates/codex-api/src/routes/komga/handlers/manifest.rs index 0ae968225..b00b8245c 100644 --- a/crates/codex-api/src/routes/komga/handlers/manifest.rs +++ b/crates/codex-api/src/routes/komga/handlers/manifest.rs @@ -63,6 +63,7 @@ pub async fn get_epub_manifest( Path(book_id): Path, ) -> Result { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; let book = BookRepository::get_by_id(&state.db, book_id) .await @@ -407,6 +408,7 @@ pub async fn get_epub_resource( Path((book_id, resource)): Path<(Uuid, String)>, ) -> Result { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Decode percent-encoded path; strip leading '/' from wildcard capture let resource = resource.strip_prefix('/').unwrap_or(&resource); diff --git a/crates/codex-api/src/routes/komga/handlers/pages.rs b/crates/codex-api/src/routes/komga/handlers/pages.rs index 97846a24f..e41d90714 100644 --- a/crates/codex-api/src/routes/komga/handlers/pages.rs +++ b/crates/codex-api/src/routes/komga/handlers/pages.rs @@ -62,6 +62,7 @@ pub async fn list_pages( Path(book_id): Path, ) -> Result>, ApiError> { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists and get book info let book = BookRepository::get_by_id(&state.db, book_id) @@ -150,6 +151,7 @@ pub async fn get_page( Path((book_id, page_number)): Path<(Uuid, i32)>, ) -> Result { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Validate page number if page_number < 1 { @@ -245,6 +247,7 @@ pub async fn get_page_thumbnail( Path((book_id, page_number)): Path<(Uuid, i32)>, ) -> Result { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Validate page number if page_number < 1 { diff --git a/crates/codex-api/src/routes/komga/handlers/read_progress.rs b/crates/codex-api/src/routes/komga/handlers/read_progress.rs index 6e8410bef..45bfa68c1 100644 --- a/crates/codex-api/src/routes/komga/handlers/read_progress.rs +++ b/crates/codex-api/src/routes/komga/handlers/read_progress.rs @@ -68,6 +68,7 @@ pub async fn update_progress( axum::Json(request): axum::Json, ) -> Result { require_permission!(auth, Permission::ProgressWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; let user_id = auth.user_id; @@ -154,6 +155,7 @@ pub async fn delete_progress( Path(book_id): Path, ) -> Result { require_permission!(auth, Permission::ProgressWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; let user_id = auth.user_id; @@ -251,6 +253,7 @@ pub async fn get_progression( Path(book_id): Path, ) -> Result { require_permission!(auth, Permission::ProgressRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists BookRepository::get_by_id(&state.db, book_id) @@ -312,6 +315,7 @@ pub async fn put_progression( axum::Json(body): axum::Json, ) -> Result { require_permission!(auth, Permission::ProgressWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; let book = BookRepository::get_by_id(&state.db, book_id) .await @@ -446,6 +450,7 @@ pub async fn mark_series_as_read( Path(series_id): Path, ) -> Result { require_permission!(auth, Permission::ProgressWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; let user_id = auth.user_id; @@ -518,6 +523,7 @@ pub async fn mark_series_as_unread( Path(series_id): Path, ) -> Result { require_permission!(auth, Permission::ProgressWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; let user_id = auth.user_id; diff --git a/crates/codex-api/src/routes/komga/handlers/readlists.rs b/crates/codex-api/src/routes/komga/handlers/readlists.rs index 75ae67a25..889b201d5 100644 --- a/crates/codex-api/src/routes/komga/handlers/readlists.rs +++ b/crates/codex-api/src/routes/komga/handlers/readlists.rs @@ -18,9 +18,10 @@ use super::collections::{ }; use crate::require_permission; use crate::routes::v1::handlers::pages::generate_book_thumbnail; +use crate::visibility::user_visibility; use crate::{ error::ApiError, - extractors::{AuthState, ContentFilter, FlexibleAuthContext}, + extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; use axum::{ @@ -79,16 +80,6 @@ fn parse_komga_readlist_sort(sort: Option<&str>) -> Option { Some(BookSortParam { field, direction }) } -async fn user_visibility( - state: &AuthState, - user_id: Uuid, -) -> Result, ApiError> { - let filter = ContentFilter::for_user(&state.db, user_id) - .await - .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {e}")))?; - Ok(filter.to_visibility()) -} - async fn build_want_to_read_dto( state: &AuthState, user_id: Uuid, @@ -149,7 +140,7 @@ pub async fn list_readlists( Query(query): Query, ) -> Result>, ApiError> { require_permission!(auth, Permission::BooksRead)?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; let read_lists = ReadListRepository::list_all(&state.db) .await @@ -193,7 +184,7 @@ pub async fn get_readlist( Path(read_list_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksRead)?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; if read_list_id == WANT_TO_READ_ID { return Ok(Json( build_want_to_read_dto(&state, auth.user_id, vis.as_ref()).await?, @@ -227,7 +218,7 @@ pub async fn get_readlist_books( Query(query): Query, ) -> Result>, ApiError> { require_permission!(auth, Permission::BooksRead)?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; let members = if read_list_id == WANT_TO_READ_ID { WantToReadRepository::queued_books(&state.db, auth.user_id, vis.as_ref()) .await @@ -334,7 +325,7 @@ pub async fn get_readlist_thumbnail( Path(read_list_id): Path, ) -> Result { auth.require_permission(&Permission::BooksRead)?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; let members = if read_list_id == WANT_TO_READ_ID { WantToReadRepository::queued_books(&state.db, auth.user_id, vis.as_ref()) .await @@ -465,7 +456,8 @@ pub async fn get_book_readlists( require_permission!(auth, Permission::BooksRead)?; let bid = Uuid::parse_str(&book_id).map_err(|_| ApiError::NotFound("Book not found".to_string()))?; - let vis = user_visibility(&state, auth.user_id).await?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, bid).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; let read_lists = ReadListRepository::get_read_lists_for_book(&state.db, bid) .await diff --git a/crates/codex-api/src/routes/komga/handlers/series.rs b/crates/codex-api/src/routes/komga/handlers/series.rs index 747d743a1..ec3906cde 100644 --- a/crates/codex-api/src/routes/komga/handlers/series.rs +++ b/crates/codex-api/src/routes/komga/handlers/series.rs @@ -146,6 +146,7 @@ pub async fn list_series( let page = query.page.max(0) as u64; let size = query.size.clamp(1, 500) as u64; + let visibility = crate::visibility::user_visibility(&state.db, auth.user_id).await?; // Build query options for database-level filtering, sorting, and pagination let options = SeriesQueryOptions { library_id: query.library_id, @@ -154,6 +155,7 @@ pub async fn list_series( sort: parse_komga_series_sort_param(query.sort.as_deref()), page, page_size: size, + visibility: visibility.as_ref(), }; let (series_list, total) = SeriesRepository::query(&state.db, options) @@ -250,9 +252,11 @@ pub async fn search_series( // If readStatus filter is present, we need to fetch all results and filter in-memory // because readStatus depends on book counts which require DTO building if read_status.is_some() { + let visibility = crate::visibility::user_visibility(&state.db, auth.user_id).await?; return search_series_with_read_status_filter( &state, user_id, + visibility.as_ref(), page, size, library_id, @@ -265,6 +269,7 @@ pub async fn search_series( .await; } + let visibility = crate::visibility::user_visibility(&state.db, auth.user_id).await?; // No readStatus filter - use efficient database-level sorting and pagination let options = SeriesQueryOptions { library_id, @@ -273,6 +278,7 @@ pub async fn search_series( sort: parse_komga_series_sort_param(query.sort.as_deref()), page, page_size: size, + visibility: visibility.as_ref(), }; let (series_list, total) = SeriesRepository::query(&state.db, options) @@ -300,6 +306,7 @@ pub async fn search_series( async fn search_series_with_read_status_filter( state: &Arc, user_id: Option, + visibility: Option<&codex_db::repositories::SeriesVisibility>, page: u64, size: u64, library_id: Option, @@ -317,6 +324,7 @@ async fn search_series_with_read_status_filter( sort: parse_komga_series_sort_param(sort_param), page: 0, page_size: i64::MAX as u64, // Fetch all for filtering (i64::MAX avoids SQLite/PG integer overflow) + visibility, }; let (series_list, _) = SeriesRepository::query(&state.db, options) @@ -406,6 +414,7 @@ pub async fn get_series_new( let page = query.page.max(0) as u64; let size = query.size.clamp(1, 500) as u64; + let visibility = crate::visibility::user_visibility(&state.db, auth.user_id).await?; // Use composable query with DateAdded sort descending let options = SeriesQueryOptions { library_id: query.library_id, @@ -417,6 +426,7 @@ pub async fn get_series_new( }), page, page_size: size, + visibility: visibility.as_ref(), }; let (series_list, total) = SeriesRepository::query(&state.db, options) @@ -482,6 +492,7 @@ pub async fn get_series_updated( let page = query.page.max(0) as u64; let size = query.size.clamp(1, 500) as u64; + let visibility = crate::visibility::user_visibility(&state.db, auth.user_id).await?; // Use composable query with DateUpdated sort descending let options = SeriesQueryOptions { library_id: query.library_id, @@ -493,6 +504,7 @@ pub async fn get_series_updated( }), page, page_size: size, + visibility: visibility.as_ref(), }; let (series_list, total) = SeriesRepository::query(&state.db, options) @@ -549,6 +561,7 @@ pub async fn get_series( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; let user_id = Some(auth.user_id); @@ -597,6 +610,7 @@ pub async fn get_series_thumbnail( Path(series_id): Path, ) -> Result { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Serve from the on-disk series thumbnail cache before touching the // database: a Komga client painting a series shelf asks for every cover at @@ -702,6 +716,7 @@ pub async fn get_series_books( Query(query): Query, ) -> Result>, ApiError> { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; let user_id = Some(auth.user_id); let page = query.page.max(0) as u64; diff --git a/crates/codex-api/src/routes/komga/handlers/stubs.rs b/crates/codex-api/src/routes/komga/handlers/stubs.rs index 6bd039e36..b8384de5f 100644 --- a/crates/codex-api/src/routes/komga/handlers/stubs.rs +++ b/crates/codex-api/src/routes/komga/handlers/stubs.rs @@ -47,11 +47,25 @@ pub async fn list_genres( ) -> Result>, ApiError> { require_permission!(auth, Permission::SeriesRead)?; + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; let genres = GenreRepository::list_all(&state.db) .await .map_err(|e| ApiError::Internal(format!("Failed to fetch genres: {}", e)))?; - let genre_names: Vec = genres.into_iter().map(|g| g.name).collect(); + // A restricted user only sees genres carried by a series they can see. + let genre_names: Vec = if filter.has_restrictions { + let visibility = filter.to_visibility(); + let counts = GenreRepository::series_counts(&state.db, visibility.as_ref()) + .await + .map_err(|e| ApiError::Internal(format!("Failed to count genres: {}", e)))?; + genres + .into_iter() + .filter(|g| counts.contains_key(&g.id)) + .map(|g| g.name) + .collect() + } else { + genres.into_iter().map(|g| g.name).collect() + }; Ok(Json(genre_names)) } @@ -88,11 +102,24 @@ pub async fn list_tags( ) -> Result>, ApiError> { require_permission!(auth, Permission::SeriesRead)?; + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; let tags = TagRepository::list_all(&state.db) .await .map_err(|e| ApiError::Internal(format!("Failed to fetch tags: {}", e)))?; - let tag_names: Vec = tags.into_iter().map(|t| t.name).collect(); + // A restricted user only sees tags carried by a series they can see. + let tag_names: Vec = if filter.has_restrictions { + let visibility = filter.to_visibility(); + let counts = TagRepository::series_counts(&state.db, visibility.as_ref()) + .await + .map_err(|e| ApiError::Internal(format!("Failed to count tags: {}", e)))?; + tags.into_iter() + .filter(|t| counts.contains_key(&t.id)) + .map(|t| t.name) + .collect() + } else { + tags.into_iter().map(|t| t.name).collect() + }; Ok(Json(tag_names)) } diff --git a/crates/codex-api/src/routes/koreader/handlers/sync.rs b/crates/codex-api/src/routes/koreader/handlers/sync.rs index 799946bf3..a47b8fb7c 100644 --- a/crates/codex-api/src/routes/koreader/handlers/sync.rs +++ b/crates/codex-api/src/routes/koreader/handlers/sync.rs @@ -27,7 +27,7 @@ pub async fn get_progress( auth.require_permission(&Permission::ProgressRead)?; let user_id = auth.user_id; - let book = find_book_by_hash(&state, &document_hash).await?; + let book = find_book_by_hash(&state, user_id, &document_hash).await?; let progress = ReadProgressRepository::get_by_user_and_book(&state.db, user_id, book.id) .await @@ -82,7 +82,7 @@ pub async fn update_progress( "KOReader progress update request" ); - let book = find_book_by_hash(&state, &request.document).await?; + let book = find_book_by_hash(&state, user_id, &request.document).await?; // Parse progress string to page number let current_page = parse_koreader_progress(&request.progress, &book.format); @@ -122,10 +122,21 @@ pub async fn update_progress( } /// Find a single book by KOReader hash, returning appropriate errors. -async fn find_book_by_hash(state: &AuthState, hash: &str) -> Result { - let books = BookRepository::find_by_koreader_hash(&state.db, hash) +/// +/// Books the user cannot see are dropped first, so a hidden book answers like a +/// missing one and a hidden copy never makes a visible one ambiguous. +async fn find_book_by_hash( + state: &AuthState, + user_id: uuid::Uuid, + hash: &str, +) -> Result { + let filter = crate::visibility::content_filter(&state.db, user_id).await?; + let books: Vec = BookRepository::find_by_koreader_hash(&state.db, hash) .await - .map_err(|e| ApiError::Internal(format!("Failed to find book: {}", e)))?; + .map_err(|e| ApiError::Internal(format!("Failed to find book: {}", e)))? + .into_iter() + .filter(|book| filter.is_book_visible(book.series_id)) + .collect(); if books.is_empty() { return Err(ApiError::NotFound( diff --git a/crates/codex-api/src/routes/opds/handlers/catalog.rs b/crates/codex-api/src/routes/opds/handlers/catalog.rs index 8b9d80481..62488720f 100644 --- a/crates/codex-api/src/routes/opds/handlers/catalog.rs +++ b/crates/codex-api/src/routes/opds/handlers/catalog.rs @@ -189,6 +189,11 @@ pub async fn list_libraries( let libraries = LibraryRepository::list_all(&state.db) .await .map_err(|e| ApiError::Internal(format!("Failed to fetch libraries: {}", e)))?; + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; + let libraries: Vec<_> = libraries + .into_iter() + .filter(|library| filter.is_library_visible(library.id)) + .collect(); let mut feed = OpdsFeed::with_author( "urn:uuid:codex-libraries", @@ -252,6 +257,7 @@ pub async fn library_series( Query(pagination): Query, ) -> Result { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; let pagination = pagination.validate(100); let now = Utc::now(); @@ -264,7 +270,7 @@ pub async fn library_series( .map_err(|e| ApiError::Internal(format!("Failed to fetch library: {}", e)))? .ok_or_else(|| ApiError::NotFound("Library not found".to_string()))?; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -458,7 +464,7 @@ pub async fn collection_series( .map_err(|e| ApiError::Internal(format!("Failed to fetch collection: {}", e)))? .ok_or_else(|| ApiError::NotFound("Collection not found".to_string()))?; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -626,7 +632,7 @@ pub async fn readlist_books( .map_err(|e| ApiError::Internal(format!("Failed to fetch read list: {}", e)))? .ok_or_else(|| ApiError::NotFound("Read list not found".to_string()))?; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -739,6 +745,7 @@ pub async fn series_books( Path(series_id): Path, ) -> Result { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; let now = Utc::now(); let base_url = "/opds"; diff --git a/crates/codex-api/src/routes/opds/handlers/pse.rs b/crates/codex-api/src/routes/opds/handlers/pse.rs index c63dfff7a..bbdf64fff 100644 --- a/crates/codex-api/src/routes/opds/handlers/pse.rs +++ b/crates/codex-api/src/routes/opds/handlers/pse.rs @@ -60,6 +60,7 @@ pub async fn book_pages( Path(book_id): Path, ) -> Result { require_permission!(auth, Permission::PagesRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; let now = Utc::now(); let base_url = "/opds"; @@ -154,6 +155,7 @@ pub async fn book_page_image( Path((book_id, page_number)): Path<(Uuid, i32)>, ) -> Result { require_permission!(auth, Permission::PagesRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Record reading progress for OPDS clients. // Fetch book to get page_count for the progress service. diff --git a/crates/codex-api/src/routes/opds/handlers/search.rs b/crates/codex-api/src/routes/opds/handlers/search.rs index 9f2271526..52359e0f6 100644 --- a/crates/codex-api/src/routes/opds/handlers/search.rs +++ b/crates/codex-api/src/routes/opds/handlers/search.rs @@ -128,7 +128,7 @@ pub async fn search( let now = Utc::now(); let base_url = "/opds"; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -155,9 +155,15 @@ pub async fn search( .add_link(OpdsLink::start_link(base_url.to_string())); // Search series by name - let series_list = SeriesRepository::search_by_name(&state.db, query, None) - .await - .map_err(|e| ApiError::Internal(format!("Failed to search series: {}", e)))?; + let series_list = SeriesRepository::search_by_name( + &state.db, + query, + crate::visibility::user_visibility(&state.db, auth.user_id) + .await? + .as_ref(), + ) + .await + .map_err(|e| ApiError::Internal(format!("Failed to search series: {}", e)))?; // Add series entries (navigation to series books) // Fetch series metadata for names (title is now in series_metadata table) diff --git a/crates/codex-api/src/routes/opds2/handlers/catalog.rs b/crates/codex-api/src/routes/opds2/handlers/catalog.rs index 0b8147001..dae24d295 100644 --- a/crates/codex-api/src/routes/opds2/handlers/catalog.rs +++ b/crates/codex-api/src/routes/opds2/handlers/catalog.rs @@ -119,6 +119,11 @@ pub async fn libraries( let libraries = LibraryRepository::list_all(&state.db) .await .map_err(|e| ApiError::Internal(format!("Failed to fetch libraries: {}", e)))?; + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; + let libraries: Vec<_> = libraries + .into_iter() + .filter(|library| filter.is_library_visible(library.id)) + .collect(); let nav_links: Vec = libraries .iter() @@ -171,6 +176,7 @@ pub async fn library_series( Query(pagination): Query, ) -> Result { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; let pagination = pagination.validate(100); let base_url = "/opds/v2"; @@ -181,7 +187,7 @@ pub async fn library_series( .map_err(|e| ApiError::Internal(format!("Failed to fetch library: {}", e)))? .ok_or_else(|| ApiError::NotFound("Library not found".to_string()))?; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -286,6 +292,7 @@ pub async fn series_books( Path(series_id): Path, ) -> Result { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; let base_url = "/opds/v2"; @@ -416,7 +423,7 @@ pub async fn recent( let pagination = pagination.validate(50); let base_url = "/opds/v2"; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -567,7 +574,7 @@ pub async fn collection_series( .map_err(|e| ApiError::Internal(format!("Failed to fetch collection: {}", e)))? .ok_or_else(|| ApiError::NotFound("Collection not found".to_string()))?; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -678,7 +685,7 @@ pub async fn readlist_books( .map_err(|e| ApiError::Internal(format!("Failed to fetch read list: {}", e)))? .ok_or_else(|| ApiError::NotFound("Read list not found".to_string()))?; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); diff --git a/crates/codex-api/src/routes/opds2/handlers/search.rs b/crates/codex-api/src/routes/opds2/handlers/search.rs index 411a05cdd..3d854f6b6 100644 --- a/crates/codex-api/src/routes/opds2/handlers/search.rs +++ b/crates/codex-api/src/routes/opds2/handlers/search.rs @@ -67,9 +67,15 @@ pub async fn search( let mut publications: Vec = Vec::new(); // Search series by name and add as navigation-like entries with links to books - let series_list = SeriesRepository::search_by_name(&state.db, query, None) - .await - .map_err(|e| ApiError::Internal(format!("Failed to search series: {}", e)))?; + let series_list = SeriesRepository::search_by_name( + &state.db, + query, + crate::visibility::user_visibility(&state.db, auth.user_id) + .await? + .as_ref(), + ) + .await + .map_err(|e| ApiError::Internal(format!("Failed to search series: {}", e)))?; // For series results, we create publications with links to browse the series // Fetch series metadata for names (title is now in series_metadata table) @@ -103,7 +109,7 @@ pub async fn search( } let user_id = auth.user_id; - let content_filter = ContentFilter::for_user(&state.db, user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); diff --git a/crates/codex-api/src/routes/v1/dto/access_group.rs b/crates/codex-api/src/routes/v1/dto/access_group.rs index 203984df1..a7f980bbe 100644 --- a/crates/codex-api/src/routes/v1/dto/access_group.rs +++ b/crates/codex-api/src/routes/v1/dto/access_group.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; +use codex_db::entities::access_groups::RuleMatch; use codex_db::entities::user_sharing_tags::AccessMode; /// Access group data transfer object @@ -23,14 +24,22 @@ pub struct AccessGroupDto { #[schema(example = "Users who can access manga content")] pub description: Option, + /// Whether new users join this group when their account is created + #[schema(example = false)] + pub is_default: bool, + /// Number of members in the group #[schema(example = 5)] pub member_count: u64, - /// Number of tag grants in the group + /// Number of sharing tag grants in the group #[schema(example = 3)] pub grant_count: u64, + /// Number of rules in the group, across sharing tags, tags, genres and libraries + #[schema(example = 5)] + pub rule_count: u64, + /// Creation timestamp #[schema(example = "2024-01-01T00:00:00Z")] pub created_at: DateTime, @@ -56,6 +65,10 @@ pub struct AccessGroupDetailDto { #[schema(example = "Users who can access manga content")] pub description: Option, + /// Whether new users join this group when their account is created + #[schema(example = false)] + pub is_default: bool, + /// Members of the group pub members: Vec, @@ -157,6 +170,21 @@ pub struct UpdateAccessGroupRequest { /// New description (set to null to remove) #[schema(example = "Updated description")] pub description: Option>, + + /// Make this the default group (clearing any other default), or stop it + /// being the default. Only affects accounts created afterwards. + #[serde(default)] + #[schema(example = true)] + pub is_default: Option, +} + +/// Result of adding every user to an access group +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddAllUsersResponse { + /// Users who were not members yet and have been added + #[schema(example = 12)] + pub added: u64, } /// Add members to an access group request @@ -263,13 +291,16 @@ impl AccessGroupDto { model: codex_db::entities::access_groups::Model, member_count: u64, grant_count: u64, + rule_count: u64, ) -> Self { Self { id: model.id, name: model.name, description: model.description, + is_default: model.is_default, member_count, grant_count, + rule_count, created_at: model.created_at, updated_at: model.updated_at, } @@ -285,3 +316,176 @@ impl From for AccessGroupSummaryDto { } } } + +// ==================== Rules ==================== + +/// Every rule of an access group +/// +/// Within a group, values of the same type combine as "any of" or "all of" +/// (per the match field for that type; libraries are always "any of"), and +/// different types combine with AND. Across groups, what each group allows is +/// combined with OR. Denies from every group always win. Admins and +/// maintainers are never restricted. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AccessGroupRulesDto { + /// How allowed sharing tags combine + pub sharing_tag_match: RuleMatch, + /// How allowed tags combine + pub tag_match: RuleMatch, + /// How allowed genres combine + pub genre_match: RuleMatch, + /// Rules on sharing tags + pub sharing_tags: Vec, + /// Rules on regular tags, matched by name + pub tags: Vec, + /// Rules on genres, matched by name + pub genres: Vec, + /// Rules on libraries + pub libraries: Vec, +} + +/// A rule on a sharing tag +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SharingTagRuleDto { + /// Sharing tag ID + pub sharing_tag_id: Uuid, + /// Sharing tag name + #[schema(example = "Kids")] + pub name: String, + /// Allow or deny + pub access_mode: AccessMode, +} + +/// A rule on a regular tag or a genre +/// +/// Matched by name, case-insensitively, so the rule keeps applying when the +/// tag or genre is removed by cleanup and created again later. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct NameRuleDto { + /// Tag or genre name + #[schema(example = "Adult")] + pub name: String, + /// Allow or deny + pub access_mode: AccessMode, +} + +/// A rule on a library +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct LibraryRuleDto { + /// Library ID + pub library_id: Uuid, + /// Library name + #[schema(example = "Manga")] + pub name: String, + /// Allow or deny + pub access_mode: AccessMode, +} + +/// Replace every rule of an access group +/// +/// Omitted lists are treated as empty and omitted match fields as "any". +#[derive(Debug, Default, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase", default)] +pub struct ReplaceAccessGroupRulesRequest { + /// How allowed sharing tags combine + pub sharing_tag_match: RuleMatch, + /// How allowed tags combine + pub tag_match: RuleMatch, + /// How allowed genres combine + pub genre_match: RuleMatch, + /// Rules on sharing tags + pub sharing_tags: Vec, + /// Rules on regular tags, matched by name + pub tags: Vec, + /// Rules on genres, matched by name + pub genres: Vec, + /// Rules on libraries + pub libraries: Vec, +} + +/// A sharing tag rule in a replace request +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SharingTagRuleInput { + /// Sharing tag ID + pub sharing_tag_id: Uuid, + /// Allow or deny + pub access_mode: AccessMode, +} + +/// A library rule in a replace request +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct LibraryRuleInput { + /// Library ID + pub library_id: Uuid, + /// Allow or deny + pub access_mode: AccessMode, +} + +// ==================== Visibility preview ==================== + +/// What a user can see, for an admin checking an access setup +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct VisibilityPreviewDto { + /// The previewed user + pub user_id: Uuid, + /// False for admins and maintainers, and for users no rule applies to + pub restricted: bool, + /// The user's role exempts them from access groups (admin or maintainer) + pub role_bypass: bool, + /// Series the user can see + pub visible_series_count: u64, + /// Series hidden from the user + pub hidden_series_count: u64, + /// Every library, and whether the user can see it + pub libraries: Vec, + /// Present when a series was asked about + pub series: Option, +} + +/// A library and whether the previewed user can see it +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct LibraryVisibilityDto { + pub id: Uuid, + pub name: String, + pub visible: bool, +} + +/// Whether the previewed user can see one series, and why +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SeriesAccessDto { + pub series_id: Uuid, + pub title: String, + pub visible: bool, + pub decision: codex_services::content_filter::AccessDecision, + /// Every deny rule that matches this series + pub denied_by: Vec, + /// Every group (or personal grant) whose allow rules let it through + pub allowed_by: Vec, +} + +/// A deny rule that matches a series +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct MatchedRuleDto { + pub rule_type: codex_services::content_filter::RuleTypeLabel, + /// The tag or genre name, or the sharing tag or library name + pub value: String, + pub source: GrantSourceDto, +} + +/// Query parameters for the visibility preview +#[derive(Debug, Deserialize, utoipa::IntoParams)] +#[serde(rename_all = "camelCase")] +pub struct VisibilityPreviewQuery { + /// Explain whether the user can see this series + pub series_id: Option, +} diff --git a/crates/codex-api/src/routes/v1/dto/metrics.rs b/crates/codex-api/src/routes/v1/dto/metrics.rs index 85b030bb5..b11640ed0 100644 --- a/crates/codex-api/src/routes/v1/dto/metrics.rs +++ b/crates/codex-api/src/routes/v1/dto/metrics.rs @@ -48,8 +48,8 @@ pub struct MetricsDto { #[serde(rename_all = "camelCase")] #[into_params(rename_all = "camelCase")] pub struct InventoryMetricsQuery { - /// Count pages exactly instead of estimating. Admin only: on PostgreSQL an - /// exact count is a full scan of the largest table. + /// Count pages exactly instead of estimating. On PostgreSQL an exact count + /// is a full scan of the largest table. #[serde(default)] pub exact: bool, } diff --git a/crates/codex-api/src/routes/v1/dto/user.rs b/crates/codex-api/src/routes/v1/dto/user.rs index f3b3af9cc..f7fa1adbe 100644 --- a/crates/codex-api/src/routes/v1/dto/user.rs +++ b/crates/codex-api/src/routes/v1/dto/user.rs @@ -44,6 +44,26 @@ pub struct UserDto { /// Last account update timestamp #[schema(example = "2024-01-15T10:30:00Z")] pub updated_at: DateTime, + + /// Access groups the user belongs to, sorted by name + pub access_groups: Vec, +} + +/// An access group membership as shown on a user +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UserAccessGroupDto { + /// Access group ID + #[schema(example = "550e8400-e29b-41d4-a716-446655440000")] + pub id: uuid::Uuid, + + /// Access group name + #[schema(example = "Kids")] + pub name: String, + + /// Membership source (manual or oidc) + #[schema(example = "manual")] + pub source: String, } /// User detail DTO with sharing tag grants (for single user view) diff --git a/crates/codex-api/src/routes/v1/handlers/access_groups.rs b/crates/codex-api/src/routes/v1/handlers/access_groups.rs index bc66f9fc5..8201f8af4 100644 --- a/crates/codex-api/src/routes/v1/handlers/access_groups.rs +++ b/crates/codex-api/src/routes/v1/handlers/access_groups.rs @@ -1,13 +1,20 @@ //! Handlers for access group operations //! -//! Access groups bundle a set of sharing-tag allow/deny rules that can be -//! assigned to multiple users. All endpoints require admin permissions. +//! Access groups bundle allow/deny rules on sharing tags, tags, genres and +//! libraries, and are assigned to many users. All endpoints require admin +//! permissions. use super::super::dto::{ AccessGroupDetailDto, AccessGroupDto, AccessGroupGrantDto, AccessGroupMemberDto, - AccessGroupOidcMappingDto, AccessGroupSummaryDto, AddAccessGroupGrantRequest, - AddAccessGroupMembersRequest, AddAccessGroupOidcMappingRequest, CreateAccessGroupRequest, - EffectiveGrantDto, EffectiveGrantsResponse, GrantSourceDto, UpdateAccessGroupRequest, + AccessGroupOidcMappingDto, AccessGroupRulesDto, AccessGroupSummaryDto, + AddAccessGroupGrantRequest, AddAccessGroupMembersRequest, AddAccessGroupOidcMappingRequest, + AddAllUsersResponse, CreateAccessGroupRequest, EffectiveGrantDto, EffectiveGrantsResponse, + GrantSourceDto, LibraryRuleDto, NameRuleDto, ReplaceAccessGroupRulesRequest, SharingTagRuleDto, + UpdateAccessGroupRequest, +}; +use crate::routes::v1::dto::{ + LibraryVisibilityDto, MatchedRuleDto, SeriesAccessDto, VisibilityPreviewDto, + VisibilityPreviewQuery, }; use crate::{ error::ApiError, @@ -16,11 +23,17 @@ use crate::{ }; use axum::{ Json, - extract::{Path, State}, + extract::{Path, Query, State}, http::StatusCode, }; +use codex_db::entities::sharing_tags; use codex_db::entities::user_access_groups::MembershipSource; -use codex_db::repositories::{AccessGroupRepository, SharingTagRepository}; +use codex_db::repositories::{ + AccessGroupRepository, AccessGroupRules, AccessGroupRulesRepository, LibraryRepository, + LibraryRule, NameRule, SharingTagRepository, SharingTagRule, +}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; @@ -50,20 +63,25 @@ pub async fn list_access_groups( .await .map_err(|e| ApiError::Internal(format!("Failed to fetch access groups: {}", e)))?; + let group_ids: Vec = groups.iter().map(|g| g.id).collect(); + let rules = AccessGroupRulesRepository::load_for_groups(&state.db, &group_ids) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch access group rules: {}", e)))?; + let mut items = Vec::with_capacity(groups.len()); for group in groups { let member_count = AccessGroupRepository::list_members(&state.db, group.id) .await .map(|m| m.len() as u64) .unwrap_or(0); - let grant_count = AccessGroupRepository::list_grants(&state.db, group.id) - .await - .map(|g| g.len() as u64) - .unwrap_or(0); + let group_rules = rules.get(&group.id); + let grant_count = group_rules.map_or(0, |r| r.sharing_tags.len() as u64); + let rule_count = group_rules.map_or(0, rule_count); items.push(AccessGroupDto::from_model_with_counts( group, member_count, grant_count, + rule_count, )); } @@ -150,7 +168,7 @@ pub async fn create_access_group( Ok(( StatusCode::CREATED, - Json(AccessGroupDto::from_model_with_counts(group, 0, 0)), + Json(AccessGroupDto::from_model_with_counts(group, 0, 0, 0)), )) } @@ -200,25 +218,34 @@ pub async fn update_access_group( } } - let group = + let mut group = AccessGroupRepository::update(&state.db, group_id, request.name, request.description) .await .map_err(|e| ApiError::Internal(format!("Failed to update access group: {}", e)))? .ok_or_else(|| ApiError::NotFound("Access group not found".to_string()))?; + if let Some(is_default) = request.is_default + && is_default != group.is_default + { + group = AccessGroupRepository::set_default(&state.db, group_id, is_default) + .await + .map_err(|e| ApiError::Internal(format!("Failed to set default group: {}", e)))?; + } + let member_count = AccessGroupRepository::list_members(&state.db, group.id) .await .map(|m| m.len() as u64) .unwrap_or(0); - let grant_count = AccessGroupRepository::list_grants(&state.db, group.id) + let rules = AccessGroupRulesRepository::get(&state.db, group.id) .await - .map(|g| g.len() as u64) - .unwrap_or(0); + .map_err(|e| ApiError::Internal(format!("Failed to fetch access group rules: {}", e)))? + .unwrap_or_default(); Ok(Json(AccessGroupDto::from_model_with_counts( group, member_count, - grant_count, + rules.sharing_tags.len() as u64, + rule_count(&rules), ))) } @@ -304,6 +331,47 @@ pub async fn add_access_group_members( Ok(Json(members)) } +/// Add every user who is not yet a member to an access group (admin only) +/// +/// Meant for a group that has just become the default, since the default only +/// applies to accounts created afterwards. Existing memberships keep their +/// source; new ones are recorded as `default`. +#[utoipa::path( + post, + path = "/api/v1/access-groups/{group_id}/add-all-users", + params( + ("group_id" = Uuid, Path, description = "Access group ID") + ), + responses( + (status = 200, description = "Users added", body = AddAllUsersResponse), + (status = 404, description = "Access group not found"), + (status = 403, description = "Forbidden - Missing permission"), + ), + security( + ("jwt_bearer" = []), + ("api_key" = []) + ), + tag = "Access Groups" +)] +pub async fn add_all_users_to_access_group( + State(state): State>, + auth: AuthContext, + Path(group_id): Path, +) -> Result, ApiError> { + auth.require_permission(&Permission::SystemAdmin)?; + + AccessGroupRepository::get_by_id(&state.db, group_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch access group: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Access group not found".to_string()))?; + + let added = AccessGroupRepository::add_all_users(&state.db, group_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to add users: {}", e)))?; + + Ok(Json(AddAllUsersResponse { added })) +} + /// Remove a user from an access group (admin only) #[utoipa::path( delete, @@ -564,6 +632,355 @@ pub async fn remove_access_group_oidc_mapping( Ok(StatusCode::NO_CONTENT) } +// ==================== Rules ==================== + +/// Get every rule of an access group (admin only) +#[utoipa::path( + get, + path = "/api/v1/access-groups/{group_id}/rules", + params( + ("group_id" = Uuid, Path, description = "Access group ID") + ), + responses( + (status = 200, description = "The group's rules", body = AccessGroupRulesDto), + (status = 404, description = "Access group not found"), + (status = 403, description = "Forbidden - Missing permission"), + ), + security( + ("jwt_bearer" = []), + ("api_key" = []) + ), + tag = "Access Groups" +)] +pub async fn get_access_group_rules( + State(state): State>, + auth: AuthContext, + Path(group_id): Path, +) -> Result, ApiError> { + auth.require_permission(&Permission::SystemAdmin)?; + + let rules = AccessGroupRulesRepository::get(&state.db, group_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch access group rules: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Access group not found".to_string()))?; + + Ok(Json(rules_to_dto(&state.db, rules).await?)) +} + +/// Replace every rule of an access group (admin only) +/// +/// The whole rule set is replaced atomically: anything not in the request is +/// removed. Sharing tag and library IDs must exist; tag and genre names need +/// not, so a rule can be set up before any series carries the name. +#[utoipa::path( + put, + path = "/api/v1/access-groups/{group_id}/rules", + params( + ("group_id" = Uuid, Path, description = "Access group ID") + ), + request_body = ReplaceAccessGroupRulesRequest, + responses( + (status = 200, description = "Rules replaced", body = AccessGroupRulesDto), + (status = 400, description = "Duplicate rule, blank name, or unknown sharing tag or library"), + (status = 404, description = "Access group not found"), + (status = 403, description = "Forbidden - Missing permission"), + ), + security( + ("jwt_bearer" = []), + ("api_key" = []) + ), + tag = "Access Groups" +)] +pub async fn replace_access_group_rules( + State(state): State>, + auth: AuthContext, + Path(group_id): Path, + Json(request): Json, +) -> Result, ApiError> { + auth.require_permission(&Permission::SystemAdmin)?; + + AccessGroupRepository::get_by_id(&state.db, group_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch access group: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Access group not found".to_string()))?; + + let rules = AccessGroupRules { + sharing_tag_match: request.sharing_tag_match, + tag_match: request.tag_match, + genre_match: request.genre_match, + sharing_tags: request + .sharing_tags + .into_iter() + .map(|r| SharingTagRule { + sharing_tag_id: r.sharing_tag_id, + access_mode: r.access_mode, + }) + .collect(), + tags: request + .tags + .into_iter() + .map(|r| NameRule::new(r.name, r.access_mode)) + .collect(), + genres: request + .genres + .into_iter() + .map(|r| NameRule::new(r.name, r.access_mode)) + .collect(), + libraries: request + .libraries + .into_iter() + .map(|r| LibraryRule { + library_id: r.library_id, + access_mode: r.access_mode, + }) + .collect(), + }; + rules.validate().map_err(ApiError::BadRequest)?; + + let sharing_tag_ids: Vec = rules + .sharing_tags + .iter() + .map(|r| r.sharing_tag_id) + .collect(); + let known_tags = sharing_tag_names(&state.db, &sharing_tag_ids).await?; + if let Some(missing) = sharing_tag_ids + .iter() + .find(|id| !known_tags.contains_key(id)) + { + return Err(ApiError::BadRequest(format!( + "Sharing tag {} does not exist", + missing + ))); + } + let library_ids: Vec = rules.libraries.iter().map(|r| r.library_id).collect(); + let known_libraries = LibraryRepository::get_by_ids(&state.db, &library_ids) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch libraries: {}", e)))?; + if let Some(missing) = library_ids + .iter() + .find(|id| !known_libraries.contains_key(id)) + { + return Err(ApiError::BadRequest(format!( + "Library {} does not exist", + missing + ))); + } + + AccessGroupRulesRepository::replace(&state.db, group_id, &rules) + .await + .map_err(|e| ApiError::Internal(format!("Failed to replace access group rules: {}", e)))?; + + Ok(Json(rules_to_dto(&state.db, rules).await?)) +} + +fn rule_count(rules: &AccessGroupRules) -> u64 { + (rules.sharing_tags.len() + rules.tags.len() + rules.genres.len() + rules.libraries.len()) + as u64 +} + +async fn sharing_tag_names( + db: &sea_orm::DatabaseConnection, + ids: &[Uuid], +) -> Result, ApiError> { + if ids.is_empty() { + return Ok(HashMap::new()); + } + Ok(sharing_tags::Entity::find() + .filter(sharing_tags::Column::Id.is_in(ids.to_vec())) + .all(db) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch sharing tags: {}", e)))? + .into_iter() + .map(|t| (t.id, t.name)) + .collect()) +} + +async fn rules_to_dto( + db: &sea_orm::DatabaseConnection, + rules: AccessGroupRules, +) -> Result { + let tag_ids: Vec = rules + .sharing_tags + .iter() + .map(|r| r.sharing_tag_id) + .collect(); + let tag_names = sharing_tag_names(db, &tag_ids).await?; + let library_ids: Vec = rules.libraries.iter().map(|r| r.library_id).collect(); + let libraries = LibraryRepository::get_by_ids(db, &library_ids) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch libraries: {}", e)))?; + + Ok(AccessGroupRulesDto { + sharing_tag_match: rules.sharing_tag_match, + tag_match: rules.tag_match, + genre_match: rules.genre_match, + sharing_tags: rules + .sharing_tags + .into_iter() + .map(|r| SharingTagRuleDto { + name: tag_names + .get(&r.sharing_tag_id) + .cloned() + .unwrap_or_default(), + sharing_tag_id: r.sharing_tag_id, + access_mode: r.access_mode, + }) + .collect(), + tags: rules + .tags + .into_iter() + .map(|r| NameRuleDto { + name: r.name, + access_mode: r.access_mode, + }) + .collect(), + genres: rules + .genres + .into_iter() + .map(|r| NameRuleDto { + name: r.name, + access_mode: r.access_mode, + }) + .collect(), + libraries: rules + .libraries + .into_iter() + .map(|r| LibraryRuleDto { + name: libraries + .get(&r.library_id) + .map(|l| l.name.clone()) + .unwrap_or_default(), + library_id: r.library_id, + access_mode: r.access_mode, + }) + .collect(), + }) +} + +// ==================== Visibility preview ==================== + +/// Preview what a user can see (admin only) +/// +/// Counts the series the user can and cannot see and lists every library with +/// whether they can see it. With `seriesId`, also explains that series: which +/// deny rules hide it (and from which group), or which groups let it through, +/// or that no allow rule matches it. Uses the same rules as enforcement, built +/// fresh rather than from the cache. +#[utoipa::path( + get, + path = "/api/v1/users/{user_id}/visibility-preview", + params( + ("user_id" = Uuid, Path, description = "User ID"), + VisibilityPreviewQuery + ), + responses( + (status = 200, description = "What the user can see", body = VisibilityPreviewDto), + (status = 404, description = "User or series not found"), + (status = 403, description = "Forbidden - Missing permission"), + ), + security( + ("jwt_bearer" = []), + ("api_key" = []) + ), + tag = "Access Groups" +)] +pub async fn get_visibility_preview( + State(state): State>, + auth: AuthContext, + Path(user_id): Path, + Query(query): Query, +) -> Result, ApiError> { + use codex_db::repositories::{SeriesMetadataRepository, SeriesRepository, UserRepository}; + use codex_services::content_filter::{ContentFilter, RuleSource, bypasses_content_filter}; + + auth.require_permission(&Permission::SystemAdmin)?; + + let user = UserRepository::get_by_id(&state.db, user_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))? + .ok_or_else(|| ApiError::NotFound("User not found".to_string()))?; + let filter = ContentFilter::for_user(&state.db, user_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to build content filter: {}", e)))?; + + let visibility = filter.to_visibility(); + let total = SeriesRepository::count_visible(&state.db, None) + .await + .map_err(|e| ApiError::Internal(format!("Failed to count series: {}", e)))?; + let visible = SeriesRepository::count_visible(&state.db, visibility.as_ref()) + .await + .map_err(|e| ApiError::Internal(format!("Failed to count series: {}", e)))?; + + let libraries = LibraryRepository::list_all(&state.db) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch libraries: {}", e)))? + .into_iter() + .map(|library| LibraryVisibilityDto { + visible: filter.is_library_visible(library.id), + id: library.id, + name: library.name, + }) + .collect(); + + let source_dto = |source: &RuleSource| match source { + RuleSource::Group { id, name } => GrantSourceDto { + kind: "group".to_string(), + group_id: Some(*id), + group_name: Some(name.clone()), + }, + RuleSource::Personal => GrantSourceDto { + kind: "user".to_string(), + group_id: None, + group_name: None, + }, + }; + + let series = match query.series_id { + None => None, + Some(series_id) => { + SeriesRepository::get_by_id(&state.db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch series: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Series not found".to_string()))?; + let title = SeriesMetadataRepository::get_by_series_id(&state.db, series_id) + .await + .ok() + .flatten() + .map(|m| m.title) + .unwrap_or_default(); + let explanation = ContentFilter::explain_series(&state.db, user_id, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to explain access: {}", e)))?; + Some(SeriesAccessDto { + series_id, + title, + visible: explanation.visible, + decision: explanation.decision, + denied_by: explanation + .denied_by + .iter() + .map(|rule| MatchedRuleDto { + rule_type: rule.rule_type, + value: rule.value.clone(), + source: source_dto(&rule.source), + }) + .collect(), + allowed_by: explanation.allowed_by.iter().map(source_dto).collect(), + }) + } + }; + + Ok(Json(VisibilityPreviewDto { + user_id, + restricted: filter.has_restrictions, + role_bypass: bypasses_content_filter(user.get_role()), + visible_series_count: visible, + hidden_series_count: total.saturating_sub(visible), + libraries, + series, + })) +} + // ==================== Effective Grants Debug ==================== /// Get effective grants for a user with source attribution (admin only) @@ -728,6 +1145,7 @@ async fn build_group_detail( id: group.id, name: group.name, description: group.description, + is_default: group.is_default, members, grants, oidc_mappings, diff --git a/crates/codex-api/src/routes/v1/handlers/books.rs b/crates/codex-api/src/routes/v1/handlers/books.rs index 14117573f..b5d14f0e9 100644 --- a/crates/codex-api/src/routes/v1/handlers/books.rs +++ b/crates/codex-api/src/routes/v1/handlers/books.rs @@ -895,7 +895,7 @@ pub async fn list_books( }; // Load content filter for sharing tags - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; @@ -1066,7 +1066,7 @@ pub async fn list_books_filtered( let use_relevance_order = sort.field == BookSortField::Relevance && has_fuzzy_search_query; // Every branch needs to honor sharing-tag visibility, so load it up front. - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -1282,7 +1282,7 @@ pub async fn get_book_full( .map_err(|e| ApiError::Internal(format!("Failed to fetch book: {}", e)))? .ok_or_else(|| ApiError::NotFound("Book not found".to_string()))?; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; @@ -1330,7 +1330,7 @@ pub async fn get_book( .ok_or_else(|| ApiError::NotFound("Book not found".to_string()))?; // Check sharing tag access for the book's series - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; @@ -1474,6 +1474,7 @@ pub async fn patch_book( use sea_orm::prelude::Decimal; require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) @@ -1657,8 +1658,9 @@ pub async fn get_adjacent_books( Path(book_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -1721,6 +1723,7 @@ pub async fn list_library_books( Query(query): Query, ) -> Result { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; // Validate and normalize pagination params (1-indexed) let page = query.page.max(1); @@ -1738,7 +1741,7 @@ pub async fn list_library_books( .map(|s| BookSortParam::parse(s)) .unwrap_or_default(); - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -1816,7 +1819,7 @@ pub async fn list_in_progress_books( }; let offset = (page - 1) * page_size; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -1886,6 +1889,7 @@ pub async fn list_library_in_progress_books( Query(query): Query, ) -> Result { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; // Validate and normalize pagination params (1-indexed) let page = query.page.max(1); @@ -1896,7 +1900,7 @@ pub async fn list_library_in_progress_books( }; let offset = (page - 1) * page_size; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -1964,7 +1968,7 @@ pub async fn list_on_deck_books( }; let offset = (page - 1) * page_size; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -2024,6 +2028,7 @@ pub async fn list_library_on_deck_books( Query(query): Query, ) -> Result { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; // Validate and normalize pagination params (1-indexed) let page = query.page.max(1); @@ -2034,7 +2039,7 @@ pub async fn list_library_on_deck_books( }; let offset = (page - 1) * page_size; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -2102,7 +2107,7 @@ pub async fn list_recently_added_books( }; let offset = (page - 1) * page_size; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -2171,6 +2176,7 @@ pub async fn list_library_recently_added_books( Query(query): Query, ) -> Result { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; // Validate and normalize pagination params (1-indexed) let page = query.page.max(1); @@ -2181,7 +2187,7 @@ pub async fn list_library_recently_added_books( }; let offset = (page - 1) * page_size; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -2257,7 +2263,7 @@ pub async fn list_recently_read_books( ) -> Result>, ApiError> { require_permission!(auth, Permission::BooksRead)?; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -2302,8 +2308,9 @@ pub async fn list_library_recently_read_books( Query(query): Query, ) -> Result>, ApiError> { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -2394,7 +2401,7 @@ pub async fn get_book_file( // Check sharing tag access for the book's series. This runs before the file // is touched, so a 206 is never a way around a check a 200 has to pass. - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; @@ -2483,6 +2490,7 @@ pub async fn replace_book_metadata( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) @@ -2839,6 +2847,7 @@ pub async fn patch_book_metadata( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) @@ -3446,6 +3455,7 @@ pub async fn get_book_metadata_locks( Path(book_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists BookRepository::get_by_id(&state.db, book_id) @@ -3530,6 +3540,7 @@ pub async fn update_book_metadata_locks( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) @@ -3758,6 +3769,7 @@ pub async fn upload_book_cover( mut multipart: Multipart, ) -> Result { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists and get its library_id/series_id let book = BookRepository::get_by_id(&state.db, book_id) @@ -3898,6 +3910,7 @@ pub async fn list_book_external_ids( Path(book_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists BookRepository::get_by_id(&state.db, book_id) @@ -3944,6 +3957,7 @@ pub async fn create_book_external_id( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) @@ -4003,6 +4017,7 @@ pub async fn delete_book_external_id( Path((book_id, external_id_id)): Path<(Uuid, Uuid)>, ) -> Result { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) @@ -4077,6 +4092,7 @@ pub async fn list_book_external_links( Path(book_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists BookRepository::get_by_id(&state.db, book_id) @@ -4122,6 +4138,7 @@ pub async fn create_book_external_link( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) @@ -4180,6 +4197,7 @@ pub async fn delete_book_external_link( Path((book_id, source)): Path<(Uuid, String)>, ) -> Result { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) @@ -4239,6 +4257,7 @@ pub async fn list_book_covers( Path(book_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists BookRepository::get_by_id(&state.db, book_id) @@ -4281,6 +4300,7 @@ pub async fn select_book_cover( Path((book_id, cover_id)): Path<(Uuid, Uuid)>, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) @@ -4363,6 +4383,7 @@ pub async fn reset_book_cover( Path(book_id): Path, ) -> Result { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) @@ -4430,6 +4451,7 @@ pub async fn get_book_cover_image( Path((book_id, cover_id)): Path<(Uuid, Uuid)>, ) -> Result { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists BookRepository::get_by_id(&state.db, book_id) @@ -4532,6 +4554,7 @@ pub async fn delete_book_cover( Path((book_id, cover_id)): Path<(Uuid, Uuid)>, ) -> Result { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) @@ -4697,10 +4720,14 @@ pub async fn list_books_with_errors( query.page_size.min(100) }; + // Only books the user can see, in the list and in its summary + let visibility = crate::visibility::user_visibility(&state.db, auth.user_id).await?; + // Get error counts by type (for the summary) - let error_counts = BookRepository::count_errors_by_type(&state.db, query.library_id) - .await - .map_err(|e| ApiError::Internal(format!("Failed to count errors: {}", e)))?; + let error_counts = + BookRepository::count_errors_by_type(&state.db, query.library_id, visibility.as_ref()) + .await + .map_err(|e| ApiError::Internal(format!("Failed to count errors: {}", e)))?; // Convert internal error type to DTO if provided let error_type_filter = query.error_type.map(|t| t.into()); @@ -4711,6 +4738,7 @@ pub async fn list_books_with_errors( query.series_id, error_type_filter, Window::from_page(page, page_size), + visibility.as_ref(), ) .await .map_err(|e| ApiError::Internal(format!("Failed to fetch books with errors: {}", e)))?; @@ -4830,6 +4858,7 @@ pub async fn retry_book_errors( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) @@ -4948,12 +4977,14 @@ pub async fn retry_all_book_errors( // Fetch all books with errors (unpaginated - we need all for bulk retry) // Use a large page size to get all results + let visibility = crate::visibility::user_visibility(&state.db, auth.user_id).await?; let (books_with_errors, _) = BookRepository::list_with_errors( &state.db, request.library_id, None, // No series filter for bulk retry error_type_filter, Window::unbounded(), + visibility.as_ref(), ) .await .map_err(|e| ApiError::Internal(format!("Failed to fetch books with errors: {}", e)))?; @@ -5083,7 +5114,7 @@ pub async fn list_book_pages( .ok_or_else(|| ApiError::NotFound("Book not found".to_string()))?; // Check sharing tag access for the book's series - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; @@ -5148,6 +5179,7 @@ pub async fn get_book_genres( Path(book_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists BookRepository::get_by_id(&state.db, book_id) @@ -5198,6 +5230,7 @@ pub async fn set_book_genres( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) @@ -5261,6 +5294,7 @@ pub async fn add_book_genre( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) @@ -5318,6 +5352,7 @@ pub async fn remove_book_genre( Path((book_id, genre_id)): Path<(Uuid, Uuid)>, ) -> Result { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) @@ -5379,6 +5414,7 @@ pub async fn get_book_tags( Path(book_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists BookRepository::get_by_id(&state.db, book_id) @@ -5429,6 +5465,7 @@ pub async fn set_book_tags( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) @@ -5492,6 +5529,7 @@ pub async fn add_book_tag( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) @@ -5549,6 +5587,7 @@ pub async fn remove_book_tag( Path((book_id, tag_id)): Path<(Uuid, Uuid)>, ) -> Result { require_permission!(auth, Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists let book = BookRepository::get_by_id(&state.db, book_id) diff --git a/crates/codex-api/src/routes/v1/handlers/bulk.rs b/crates/codex-api/src/routes/v1/handlers/bulk.rs index 5f7b747fd..0cb9c70ae 100644 --- a/crates/codex-api/src/routes/v1/handlers/bulk.rs +++ b/crates/codex-api/src/routes/v1/handlers/bulk.rs @@ -62,10 +62,14 @@ pub async fn bulk_mark_books_as_read( })); } - // Get book data (id, page_count) for all valid books + // Get book data (id, page_count) for every book that exists and that the + // user can see; the rest are skipped like unknown ids. + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; let mut book_data: Vec<(Uuid, i32)> = Vec::new(); for book_id in &request.book_ids { - if let Ok(Some(book)) = BookRepository::get_by_id(&state.db, *book_id).await { + if let Ok(Some(book)) = BookRepository::get_by_id(&state.db, *book_id).await + && filter.is_book_visible(book.series_id) + { book_data.push((book.id, book.page_count)); } } @@ -128,12 +132,26 @@ pub async fn bulk_mark_books_as_unread( })); } + // Books the user cannot see are skipped like unknown ids. + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; + let book_ids: Vec = if filter.has_restrictions { + BookRepository::get_by_ids(&state.db, &request.book_ids) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch books: {}", e)))? + .into_iter() + .filter(|book| filter.is_book_visible(book.series_id)) + .map(|book| book.id) + .collect() + } else { + request.book_ids + }; + // Mark all books as unread (delete progress records), attributing the // reset events to the requesting device. let count = ReadProgressRepository::mark_series_as_unread_with_device( &state.db, auth.user_id, - request.book_ids, + book_ids, &auth.client_device_context(&headers), ) .await @@ -250,13 +268,15 @@ pub async fn bulk_mark_series_as_read( let mut total_count = 0; + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; for series_id in &request.series_ids { - // Verify series exists - if SeriesRepository::get_by_id(&state.db, *series_id) - .await - .ok() - .flatten() - .is_none() + // Skip series that do not exist or that the user cannot see + if !filter.is_series_visible(*series_id) + || SeriesRepository::get_by_id(&state.db, *series_id) + .await + .ok() + .flatten() + .is_none() { continue; } @@ -335,13 +355,15 @@ pub async fn bulk_mark_series_as_unread( let mut total_count: u64 = 0; + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; for series_id in &request.series_ids { - // Verify series exists - if SeriesRepository::get_by_id(&state.db, *series_id) - .await - .ok() - .flatten() - .is_none() + // Skip series that do not exist or that the user cannot see + if !filter.is_series_visible(*series_id) + || SeriesRepository::get_by_id(&state.db, *series_id) + .await + .ok() + .flatten() + .is_none() { continue; } @@ -416,13 +438,15 @@ pub async fn bulk_analyze_series( let mut enqueued = 0; + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; for series_id in &request.series_ids { - // Verify series exists - if SeriesRepository::get_by_id(&state.db, *series_id) - .await - .ok() - .flatten() - .is_none() + // Skip series that do not exist or that the user cannot see + if !filter.is_series_visible(*series_id) + || SeriesRepository::get_by_id(&state.db, *series_id) + .await + .ok() + .flatten() + .is_none() { continue; } diff --git a/crates/codex-api/src/routes/v1/handlers/collections.rs b/crates/codex-api/src/routes/v1/handlers/collections.rs index 15563c5ce..ede0dd6d1 100644 --- a/crates/codex-api/src/routes/v1/handlers/collections.rs +++ b/crates/codex-api/src/routes/v1/handlers/collections.rs @@ -10,9 +10,10 @@ use super::super::dto::{ CreateCollectionRequest, ReorderCollectionSeriesRequest, SeriesDto, UpdateCollectionRequest, }; use crate::require_permission; +use crate::visibility::user_visibility; use crate::{ error::ApiError, - extractors::{AuthContext, AuthState, ContentFilter, FlexibleAuthContext}, + extractors::{AuthContext, AuthState, FlexibleAuthContext}, permissions::Permission, }; use axum::{ @@ -111,16 +112,6 @@ fn reject_manual_mutation( Ok(()) } -async fn user_visibility( - state: &AuthState, - user_id: Uuid, -) -> Result, ApiError> { - let filter = ContentFilter::for_user(&state.db, user_id) - .await - .map_err(internal("Failed to load content filter"))?; - Ok(filter.to_visibility()) -} - /// List all collections. #[utoipa::path( get, @@ -138,7 +129,7 @@ pub async fn list_collections( auth: AuthContext, ) -> Result, ApiError> { require_permission!(auth, Permission::CollectionsRead)?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; let collections = CollectionRepository::list_all(&state.db) .await @@ -251,7 +242,7 @@ pub async fn get_collection( .await .map_err(internal("Failed to fetch collection"))? .ok_or_else(|| ApiError::NotFound("Collection not found".to_string()))?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; Ok(Json(collection_dto(&state.db, model, vis.as_ref()).await?)) } @@ -341,7 +332,7 @@ pub async fn update_collection( .map_err(internal("Failed to update collection"))? .ok_or_else(|| ApiError::NotFound("Collection not found".to_string()))?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; Ok(Json(collection_dto(&state.db, model, vis.as_ref()).await?)) } @@ -397,7 +388,7 @@ pub async fn get_collection_series( require_permission!(auth, Permission::CollectionsRead)?; let collection = get_collection_or_404(&state, collection_id).await?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; let members = CollectionMembershipService::members( &state.db, &collection, @@ -453,7 +444,7 @@ pub async fn add_collection_series( .map_err(internal("Failed to add series to collection"))?; } - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; Ok(Json(collection_dto(&state.db, model, vis.as_ref()).await?)) } @@ -474,6 +465,7 @@ pub async fn remove_collection_series( Path((collection_id, series_id)): Path<(Uuid, Uuid)>, ) -> Result { require_permission!(auth, Permission::CollectionsWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; let collection = get_collection_or_404(&state, collection_id).await?; reject_manual_mutation(&collection, "removed")?; CollectionRepository::remove_series(&state.db, collection_id, series_id) @@ -528,7 +520,7 @@ pub async fn get_collection_thumbnail( ) -> Result { auth.require_permission(&Permission::CollectionsRead)?; let collection = get_collection_or_404(&state, collection_id).await?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; let first = CollectionMembershipService::cover( &state.db, &collection, @@ -581,7 +573,8 @@ pub async fn get_series_collections( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::CollectionsRead)?; - let vis = user_visibility(&state, auth.user_id).await?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; let collections = CollectionRepository::get_collections_for_series(&state.db, series_id) .await diff --git a/crates/codex-api/src/routes/v1/handlers/duplicates.rs b/crates/codex-api/src/routes/v1/handlers/duplicates.rs index bf333072d..ba6c2f14a 100644 --- a/crates/codex-api/src/routes/v1/handlers/duplicates.rs +++ b/crates/codex-api/src/routes/v1/handlers/duplicates.rs @@ -47,10 +47,43 @@ pub async fn list_duplicates( // Check permission auth.require_permission(&Permission::BooksRead)?; - let duplicates = BookDuplicatesRepository::find_all(&state.db) + let mut duplicates = BookDuplicatesRepository::find_all(&state.db) .await .map_err(|e| ApiError::Internal(format!("Failed to list duplicates: {}", e)))?; + // A restricted user only sees the members they can see, and only groups + // that still hold a duplicate once the hidden members are gone. + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; + if filter.has_restrictions { + let all_ids: Vec = duplicates + .iter() + .flat_map(|d| serde_json::from_str::>(&d.book_ids).unwrap_or_default()) + .collect(); + let visible: HashSet = + codex_db::repositories::BookRepository::get_by_ids(&state.db, &all_ids) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load duplicate books: {}", e)))? + .into_iter() + .filter(|book| filter.is_book_visible(book.series_id)) + .map(|book| book.id) + .collect(); + duplicates = duplicates + .into_iter() + .filter_map(|mut d| { + let ids: Vec = serde_json::from_str::>(&d.book_ids) + .unwrap_or_default() + .into_iter() + .filter(|id| visible.contains(id)) + .collect(); + (ids.len() >= 2).then(|| { + d.duplicate_count = ids.len() as i32; + d.book_ids = serde_json::to_string(&ids).unwrap_or_default(); + d + }) + }) + .collect(); + } + let total_groups = duplicates.len(); let total_duplicate_books: usize = duplicates.iter().map(|d| d.duplicate_count as usize).sum(); @@ -228,11 +261,24 @@ pub async fn list_series_duplicates( // Decode each group's stored UUID list once so we can both batch-load // member details and reuse the parsed list when emitting DTOs. + // A restricted user only sees the members they can see, and only groups + // that still hold a duplicate once the hidden members are gone. + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; let parsed: Vec<(_, Vec)> = groups .into_iter() - .map(|g| { - let ids: Vec = serde_json::from_str(&g.series_ids).unwrap_or_default(); - (g, ids) + .filter_map(|mut g| { + let ids: Vec = serde_json::from_str::>(&g.series_ids) + .unwrap_or_default() + .into_iter() + .filter(|id| filter.is_series_visible(*id)) + .collect(); + if filter.has_restrictions { + if ids.len() < 2 { + return None; + } + g.duplicate_count = ids.len() as i32; + } + Some((g, ids)) }) .collect(); diff --git a/crates/codex-api/src/routes/v1/handlers/events.rs b/crates/codex-api/src/routes/v1/handlers/events.rs index 356cb8741..f78daa900 100644 --- a/crates/codex-api/src/routes/v1/handlers/events.rs +++ b/crates/codex-api/src/routes/v1/handlers/events.rs @@ -64,6 +64,8 @@ pub async fn entity_events_stream( // Clone broadcaster to check shutdown status let broadcaster = state.event_broadcaster.clone(); + let db = state.db.clone(); + let user_id = auth.user_id; // Create SSE stream with timeout to detect client disconnects let stream = async_stream::stream! { @@ -84,6 +86,11 @@ pub async fn entity_events_stream( break; } + // Never announce content this subscriber cannot see + if !crate::visibility::entity_event_visible(&db, user_id, &event).await { + continue; + } + // Serialize event to JSON match serde_json::to_string(&event) { Ok(json) => { @@ -190,6 +197,8 @@ pub async fn task_progress_stream( // Clone broadcaster to check shutdown status let broadcaster = state.event_broadcaster.clone(); + let db = state.db.clone(); + let user_id = auth.user_id; // Create SSE stream with timeout to detect client disconnects let stream = async_stream::stream! { @@ -210,6 +219,11 @@ pub async fn task_progress_stream( break; } + // Never report work on content this subscriber cannot see + if !crate::visibility::task_event_visible(&db, user_id, &event).await { + continue; + } + // Serialize event to JSON match serde_json::to_string(&event) { Ok(json) => { diff --git a/crates/codex-api/src/routes/v1/handlers/libraries.rs b/crates/codex-api/src/routes/v1/handlers/libraries.rs index 0fcc2fd7f..019ccaaf6 100644 --- a/crates/codex-api/src/routes/v1/handlers/libraries.rs +++ b/crates/codex-api/src/routes/v1/handlers/libraries.rs @@ -27,14 +27,23 @@ use std::sync::Arc; use uuid::Uuid; /// Helper function to convert a library entity to a DTO -async fn library_to_dto(db: &DatabaseConnection, library: libraries::Model) -> LibraryDto { - // Get counts - let book_count = codex_db::repositories::BookRepository::count_by_library(db, library.id) - .await - .ok(); - let series_count = codex_db::repositories::SeriesRepository::count_by_library(db, library.id) - .await - .ok(); +/// +/// Counts only include what `visibility` lets the user see. +async fn library_to_dto( + db: &DatabaseConnection, + library: libraries::Model, + visibility: Option<&codex_db::repositories::SeriesVisibility>, +) -> LibraryDto { + let book_count = codex_db::repositories::BookRepository::count_visible_by_library( + db, library.id, visibility, + ) + .await + .ok(); + let series_count = codex_db::repositories::SeriesRepository::count_visible_by_library( + db, library.id, visibility, + ) + .await + .ok(); // Parse allowed_formats from JSON string let allowed_formats = library @@ -140,9 +149,14 @@ pub async fn list_libraries( let page = params.page.max(1); let page_size = params.page_size.clamp(1, MAX_PAGE_SIZE); - let libraries = LibraryRepository::list_all(&state.db) + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; + let visibility = filter.to_visibility(); + let libraries: Vec<_> = LibraryRepository::list_all(&state.db) .await - .map_err(|e| ApiError::Internal(format!("Failed to fetch libraries: {}", e)))?; + .map_err(|e| ApiError::Internal(format!("Failed to fetch libraries: {}", e)))? + .into_iter() + .filter(|library| filter.is_library_visible(library.id)) + .collect(); let total = libraries.len() as u64; let total_pages = if page_size == 0 { @@ -161,7 +175,7 @@ pub async fn list_libraries( let mut dtos = Vec::new(); for library in paginated_libraries { - dtos.push(library_to_dto(&state.db, library).await); + dtos.push(library_to_dto(&state.db, library, visibility.as_ref()).await); } // Build pagination links @@ -196,13 +210,17 @@ pub async fn get_library( Path(library_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::LibrariesRead)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; let library = LibraryRepository::get_by_id(&state.db, library_id) .await .map_err(|e| ApiError::Internal(format!("Failed to fetch library: {}", e)))? .ok_or_else(|| ApiError::NotFound("Library not found".to_string()))?; + let visibility = crate::visibility::user_visibility(&state.db, auth.user_id).await?; - Ok(Json(library_to_dto(&state.db, library).await)) + Ok(Json( + library_to_dto(&state.db, library, visibility.as_ref()).await, + )) } /// Create a new library @@ -322,7 +340,10 @@ pub async fn create_library( // Don't fail the request - library was created successfully } - Ok(Json(library_to_dto(&state.db, library).await)) + let visibility = crate::visibility::user_visibility(&state.db, auth.user_id).await?; + Ok(Json( + library_to_dto(&state.db, library, visibility.as_ref()).await, + )) } /// Update a library (partial update) @@ -351,6 +372,7 @@ pub async fn update_library( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::LibrariesWrite)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; // Fetch existing library let mut library = LibraryRepository::get_by_id(&state.db, library_id) @@ -487,7 +509,10 @@ pub async fn update_library( // Don't fail the request - library was updated successfully } - Ok(Json(library_to_dto(&state.db, updated).await)) + let visibility = crate::visibility::user_visibility(&state.db, auth.user_id).await?; + Ok(Json( + library_to_dto(&state.db, updated, visibility.as_ref()).await, + )) } /// Delete a library @@ -514,6 +539,7 @@ pub async fn delete_library( Path(library_id): Path, ) -> Result<(), ApiError> { require_permission!(auth, Permission::LibrariesDelete)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; LibraryRepository::delete(&state.db, library_id) .await @@ -573,6 +599,7 @@ pub async fn purge_deleted_books( Path(library_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::LibrariesWrite)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; // Verify library exists LibraryRepository::get_by_id(&state.db, library_id) diff --git a/crates/codex-api/src/routes/v1/handlers/library_jobs.rs b/crates/codex-api/src/routes/v1/handlers/library_jobs.rs index 294fc64ab..b0779ad0f 100644 --- a/crates/codex-api/src/routes/v1/handlers/library_jobs.rs +++ b/crates/codex-api/src/routes/v1/handlers/library_jobs.rs @@ -136,6 +136,7 @@ pub async fn list_jobs( Path(library_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::LibrariesRead)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; ensure_library_exists(&state, library_id).await?; let rows = LibraryJobRepository::list_for_library(&state.db, library_id) .await @@ -172,6 +173,7 @@ pub async fn get_job( Path((library_id, job_id)): Path<(Uuid, Uuid)>, ) -> Result, ApiError> { require_permission!(auth, Permission::LibrariesRead)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; let row = LibraryJobRepository::get_by_id(&state.db, job_id) .await .map_err(|e| anyhow_to_api_error(e, "Failed to load job"))? @@ -213,6 +215,7 @@ pub async fn create_job( Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { require_permission!(auth, Permission::LibrariesWrite)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; ensure_library_exists(&state, library_id).await?; // Determine name (auto-generate when blank). @@ -298,6 +301,7 @@ pub async fn patch_job( Json(patch): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::LibrariesWrite)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; let mut row = LibraryJobRepository::get_by_id(&state.db, job_id) .await .map_err(|e| anyhow_to_api_error(e, "Failed to load job"))? @@ -394,6 +398,7 @@ pub async fn delete_job( Path((library_id, job_id)): Path<(Uuid, Uuid)>, ) -> Result { require_permission!(auth, Permission::LibrariesWrite)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; let row = LibraryJobRepository::get_by_id(&state.db, job_id) .await .map_err(|e| anyhow_to_api_error(e, "Failed to load job"))? @@ -449,6 +454,7 @@ pub async fn run_job_now( Path((library_id, job_id)): Path<(Uuid, Uuid)>, ) -> Result, ApiError> { require_permission!(auth, Permission::LibrariesWrite)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; let row = LibraryJobRepository::get_by_id(&state.db, job_id) .await .map_err(|e| anyhow_to_api_error(e, "Failed to load job"))? @@ -517,6 +523,7 @@ pub async fn dry_run_job( Json(body): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::LibrariesWrite)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; let row = LibraryJobRepository::get_by_id(&state.db, job_id) .await .map_err(|e| anyhow_to_api_error(e, "Failed to load job"))? diff --git a/crates/codex-api/src/routes/v1/handlers/metrics.rs b/crates/codex-api/src/routes/v1/handlers/metrics.rs index ae80dd03f..5898e4eb6 100644 --- a/crates/codex-api/src/routes/v1/handlers/metrics.rs +++ b/crates/codex-api/src/routes/v1/handlers/metrics.rs @@ -30,7 +30,7 @@ use codex_db::repositories::MetricsRepository; params(InventoryMetricsQuery), responses( (status = 200, description = "Inventory metrics retrieved successfully", body = MetricsDto), - (status = 403, description = "Permission denied, or `exact=true` requested without admin access"), + (status = 403, description = "Requires tasks:read (admins and maintainers)"), ), security( ("jwt_bearer" = []), @@ -43,13 +43,11 @@ pub async fn get_inventory_metrics( auth: AuthContext, Query(query): Query, ) -> Result, ApiError> { - // Check permission - metrics are considered system-level library information - auth.require_permission(&Permission::LibrariesRead)?; - // Refuse rather than fall back to the estimate, so a caller that asked for - // an exact count never receives an estimate believing it is exact. - if query.exact { - auth.require_admin()?; - } + // Server metrics (user count, database size, every library's totals) are + // for the people who run the server: admins and maintainers, who also see + // them from the iOS app's Tasks screen. Readers have no use for them, and + // the totals would tell a restricted reader how much is hidden from them. + auth.require_permission(&Permission::TasksRead)?; // Gather all metrics in parallel let ( @@ -146,7 +144,7 @@ pub async fn get_inventory_metrics( path = "/api/v1/metrics/plugins", responses( (status = 200, description = "Plugin metrics retrieved successfully", body = PluginMetricsResponse), - (status = 403, description = "Permission denied"), + (status = 403, description = "Requires tasks:read (admins and maintainers)"), ), security( ("jwt_bearer" = []), @@ -158,7 +156,7 @@ pub async fn get_plugin_metrics( State(state): State>, auth: AuthContext, ) -> Result, ApiError> { - auth.require_permission(&Permission::LibrariesRead)?; + auth.require_permission(&Permission::TasksRead)?; let summary = state.plugin_metrics_service.get_summary().await; let plugin_snapshots = state.plugin_metrics_service.get_all_metrics().await; diff --git a/crates/codex-api/src/routes/v1/handlers/oidc.rs b/crates/codex-api/src/routes/v1/handlers/oidc.rs index c73765639..fa7735ff7 100644 --- a/crates/codex-api/src/routes/v1/handlers/oidc.rs +++ b/crates/codex-api/src/routes/v1/handlers/oidc.rs @@ -294,20 +294,7 @@ pub async fn callback( .await .map_err(|e| ApiError::Internal(format!("Failed to update OIDC connection: {}", e)))?; - // Reconcile OIDC-sourced access group memberships - if let Err(e) = AccessGroupRepository::reconcile_oidc_group_memberships( - &state.db, - user.id, - &auth_result.groups, - ) - .await - { - warn!( - user_id = %user.id, - error = %e, - "Failed to reconcile OIDC access group memberships (login continues)" - ); - } + sync_access_groups_from_oidc(&state.db, user.id, &auth_result.groups).await?; // Sync role from IdP groups on every login let user = sync_role_from_groups(&state.db, user, &auth_result.mapped_role).await?; @@ -415,20 +402,7 @@ pub async fn callback( .await .map_err(|e| ApiError::Internal(format!("Failed to create OIDC connection: {}", e)))?; - // Reconcile OIDC-sourced access group memberships for new connection - if let Err(e) = AccessGroupRepository::reconcile_oidc_group_memberships( - &state.db, - user.id, - &auth_result.groups, - ) - .await - { - warn!( - user_id = %user.id, - error = %e, - "Failed to reconcile OIDC access group memberships (login continues)" - ); - } + sync_access_groups_from_oidc(&state.db, user.id, &auth_result.groups).await?; (user, is_new) }; @@ -559,6 +533,28 @@ fn redirect_response(url: &str) -> Result { }) } +/// Reconcile the user's OIDC-sourced access group memberships with the IdP's groups. +/// +/// A failure here fails the login. Continuing would leave the user with stale or +/// missing memberships, and a user with no groups sees the whole library. +async fn sync_access_groups_from_oidc( + db: &sea_orm::DatabaseConnection, + user_id: Uuid, + oidc_groups: &[String], +) -> Result<(), ApiError> { + AccessGroupRepository::reconcile_oidc_group_memberships(db, user_id, oidc_groups) + .await + .map(|_| ()) + .map_err(|e| { + warn!( + user_id = %user_id, + error = %e, + "Failed to reconcile OIDC access group memberships, rejecting login" + ); + ApiError::Internal("Failed to sync access groups".to_string()) + }) +} + /// Sync user role from IdP group mapping on every OIDC login. /// /// If the mapped role differs from the current role, updates the user in the database. @@ -960,5 +956,65 @@ mod tests { let updated = sync_role_from_groups(&db, created, "reader").await.unwrap(); assert_eq!(updated.role, "reader"); } + + #[tokio::test] + async fn test_sync_access_groups_from_oidc_maps_groups() { + let db = setup_test_db().await; + let user = UserRepository::create(&db, &test_user("mapped")) + .await + .unwrap(); + let group = AccessGroupRepository::create(&db, "Kids", None) + .await + .unwrap(); + AccessGroupRepository::add_oidc_mapping(&db, group.id, "idp-kids") + .await + .unwrap(); + + sync_access_groups_from_oidc(&db, user.id, &["idp-kids".to_string()]) + .await + .unwrap(); + + let groups = AccessGroupRepository::list_for_user(&db, user.id) + .await + .unwrap(); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].id, group.id); + } + + /// A user whose group sync fails would otherwise log in with no + /// groups, which under default-open visibility means seeing the whole + /// library. The sync failure has to fail the login instead. + #[tokio::test] + async fn test_sync_access_groups_from_oidc_fails_when_reconcile_fails() { + use sea_orm::ConnectionTrait; + + let db = setup_test_db().await; + let user = UserRepository::create(&db, &test_user("broken")) + .await + .unwrap(); + db.execute_unprepared("DROP TABLE access_group_oidc_mappings") + .await + .unwrap(); + + let result = + sync_access_groups_from_oidc(&db, user.id, &["idp-kids".to_string()]).await; + assert!(matches!(result, Err(ApiError::Internal(_)))); + } + + fn test_user(username: &str) -> users::Model { + users::Model { + id: Uuid::new_v4(), + username: username.to_string(), + email: format!("{username}@example.com"), + password_hash: "hash".to_string(), + role: "reader".to_string(), + is_active: true, + email_verified: false, + permissions: serde_json::json!([]), + created_at: Utc::now(), + updated_at: Utc::now(), + last_login_at: None, + } + } } } diff --git a/crates/codex-api/src/routes/v1/handlers/pages.rs b/crates/codex-api/src/routes/v1/handlers/pages.rs index 7d3d5eb8d..0188fc20d 100644 --- a/crates/codex-api/src/routes/v1/handlers/pages.rs +++ b/crates/codex-api/src/routes/v1/handlers/pages.rs @@ -76,6 +76,7 @@ pub async fn get_page_image( Query(query): Query, ) -> Result { require_permission!(auth, Permission::PagesRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Validate page number if page_number < 1 { @@ -462,6 +463,7 @@ pub async fn get_book_thumbnail( Path(book_id): Path, ) -> Result { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // OPTIMIZATION: Check disk cache FIRST before hitting the database. // This avoids acquiring a DB connection for cached thumbnails, preventing diff --git a/crates/codex-api/src/routes/v1/handlers/plugin_actions.rs b/crates/codex-api/src/routes/v1/handlers/plugin_actions.rs index a68aeefc1..6567a4d58 100644 --- a/crates/codex-api/src/routes/v1/handlers/plugin_actions.rs +++ b/crates/codex-api/src/routes/v1/handlers/plugin_actions.rs @@ -553,10 +553,12 @@ pub struct SearchTitleQuery { )] pub async fn get_series_search_title( State(state): State>, - _auth: AuthContext, + auth: AuthContext, Path(series_id): Path, Query(query): Query, ) -> Result, ApiError> { + auth.require_permission(&Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Get the series let series = SeriesRepository::get_by_id(&state.db, series_id) .await @@ -671,6 +673,7 @@ pub async fn preview_series_metadata( ) -> Result, ApiError> { // Check permission to edit series metadata auth.require_permission(&Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Get the series (verify it exists) let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -1393,6 +1396,7 @@ pub async fn apply_series_metadata( ) -> Result, ApiError> { // Check permission to edit series metadata auth.require_permission(&Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Get the series (verify it exists and get library_id for events) let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -1551,6 +1555,7 @@ pub async fn auto_match_series_metadata( ) -> Result, ApiError> { // Check permission to edit series metadata auth.require_permission(&Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Get the series (verify it exists and get its title) let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -1745,6 +1750,7 @@ pub async fn preview_book_metadata( ) -> Result, ApiError> { // Check permission to edit book metadata auth.require_permission(&Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Get the book (verify it exists and get library_id) let book = BookRepository::get_by_id(&state.db, book_id) @@ -2302,6 +2308,7 @@ pub async fn apply_book_metadata( ) -> Result, ApiError> { // Check permission to edit book metadata auth.require_permission(&Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Dry-run is currently only supported on the series apply path. Reject // it explicitly here so the user doesn't get a silent real-apply. @@ -2628,6 +2635,7 @@ pub async fn enqueue_auto_match_task( ) -> Result, ApiError> { // Check permission to edit series metadata auth.require_permission(&Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -2835,6 +2843,7 @@ pub async fn enqueue_library_auto_match_tasks( ) -> Result, ApiError> { // Check permission to edit series metadata auth.require_permission(&Permission::SeriesWrite)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; // Verify library exists let _library = LibraryRepository::get_by_id(&state.db, library_id) diff --git a/crates/codex-api/src/routes/v1/handlers/read_progress.rs b/crates/codex-api/src/routes/v1/handlers/read_progress.rs index bea54dfdc..2dbf8789d 100644 --- a/crates/codex-api/src/routes/v1/handlers/read_progress.rs +++ b/crates/codex-api/src/routes/v1/handlers/read_progress.rs @@ -75,6 +75,7 @@ pub async fn update_reading_progress( ) -> Result, ApiError> { // Check permission - users can manage their own reading progress auth.require_permission(&Permission::ProgressWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Look up the book to get its page count for auto-completion detection let book = BookRepository::get_by_id(&state.db, book_id) @@ -139,6 +140,7 @@ pub async fn get_reading_progress( ) -> Result { // Check permission auth.require_permission(&Permission::ProgressRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify the book exists BookRepository::get_by_id(&state.db, book_id) @@ -184,6 +186,7 @@ pub async fn delete_reading_progress( ) -> Result { // Check permission auth.require_permission(&Permission::ProgressWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Delete progress ReadProgressRepository::delete_with_device( @@ -255,6 +258,7 @@ pub async fn mark_book_as_read( ) -> Result, ApiError> { // Check permission auth.require_permission(&Permission::ProgressWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Get the book to get its page count let book = BookRepository::get_by_id(&state.db, book_id) @@ -300,6 +304,7 @@ pub async fn mark_book_as_unread( ) -> Result { // Check permission auth.require_permission(&Permission::ProgressWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Mark as unread (delete progress) ReadProgressRepository::delete_with_device( @@ -339,6 +344,7 @@ pub async fn get_progression( Path(book_id): Path, ) -> Result { auth.require_permission(&Permission::ProgressRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; BookRepository::get_by_id(&state.db, book_id) .await @@ -386,6 +392,7 @@ pub async fn put_progression( Json(body): Json, ) -> Result { auth.require_permission(&Permission::ProgressWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; let book = BookRepository::get_by_id(&state.db, book_id) .await @@ -524,6 +531,7 @@ pub async fn get_book_read_history( Path(book_id): Path, ) -> Result, ApiError> { auth.require_permission(&Permission::ProgressRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; BookRepository::get_by_id(&state.db, book_id) .await @@ -567,6 +575,7 @@ pub async fn clear_book_read_history( Path(book_id): Path, ) -> Result { auth.require_permission(&Permission::ProgressWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; BookRepository::get_by_id(&state.db, book_id) .await @@ -616,6 +625,7 @@ pub async fn delete_book_read_history_entry( Path((book_id, completion_id)): Path<(Uuid, Uuid)>, ) -> Result { auth.require_permission(&Permission::ProgressWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; BookRepository::get_by_id(&state.db, book_id) .await @@ -666,6 +676,7 @@ pub async fn get_series_read_history( Path(series_id): Path, ) -> Result, ApiError> { auth.require_permission(&Permission::ProgressRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; SeriesRepository::get_by_id(&state.db, series_id) .await @@ -717,6 +728,7 @@ pub async fn clear_series_read_history( Path(series_id): Path, ) -> Result { auth.require_permission(&Permission::ProgressWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; SeriesRepository::get_by_id(&state.db, series_id) .await diff --git a/crates/codex-api/src/routes/v1/handlers/reading_sessions.rs b/crates/codex-api/src/routes/v1/handlers/reading_sessions.rs index 5157d0ea0..60867106f 100644 --- a/crates/codex-api/src/routes/v1/handlers/reading_sessions.rs +++ b/crates/codex-api/src/routes/v1/handlers/reading_sessions.rs @@ -109,13 +109,16 @@ pub async fn record_reading_sessions( continue; } + // A book the user cannot see is rejected exactly like one that does + // not exist. let book_exists = match known_books.get(&session.book_id) { Some(exists) => *exists, None => { + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; let exists = BookRepository::get_by_id(&state.db, session.book_id) .await .map_err(|e| ApiError::Internal(format!("Failed to get book: {}", e)))? - .is_some(); + .is_some_and(|book| filter.is_book_visible(book.series_id)); known_books.insert(session.book_id, exists); exists } diff --git a/crates/codex-api/src/routes/v1/handlers/readlists.rs b/crates/codex-api/src/routes/v1/handlers/readlists.rs index 45f25249e..b6b61bace 100644 --- a/crates/codex-api/src/routes/v1/handlers/readlists.rs +++ b/crates/codex-api/src/routes/v1/handlers/readlists.rs @@ -10,9 +10,10 @@ use super::super::dto::{ ReadListListResponse, ReorderReadListBooksRequest, UpdateReadListRequest, }; use crate::require_permission; +use crate::visibility::user_visibility; use crate::{ error::ApiError, - extractors::{AuthContext, AuthState, ContentFilter, FlexibleAuthContext}, + extractors::{AuthContext, AuthState, FlexibleAuthContext}, permissions::Permission, }; use axum::{ @@ -73,16 +74,6 @@ async fn readlist_dto( Ok(ReadListDto::from_model(model, count)) } -async fn user_visibility( - state: &AuthState, - user_id: Uuid, -) -> Result, ApiError> { - let filter = ContentFilter::for_user(&state.db, user_id) - .await - .map_err(internal("Failed to load content filter"))?; - Ok(filter.to_visibility()) -} - async fn ensure_readlist_exists(state: &AuthState, read_list_id: Uuid) -> Result<(), ApiError> { get_readlist_or_404(state, read_list_id).await.map(|_| ()) } @@ -114,7 +105,7 @@ pub async fn list_readlists( auth: AuthContext, ) -> Result, ApiError> { require_permission!(auth, Permission::ReadListsRead)?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; let read_lists = ReadListRepository::list_all(&state.db) .await @@ -194,7 +185,7 @@ pub async fn get_readlist( .await .map_err(internal("Failed to fetch read list"))? .ok_or_else(|| ApiError::NotFound("Read list not found".to_string()))?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; Ok(Json(readlist_dto(&state.db, model, vis.as_ref()).await?)) } @@ -251,7 +242,7 @@ pub async fn update_readlist( .map_err(internal("Failed to update read list"))? .ok_or_else(|| ApiError::NotFound("Read list not found".to_string()))?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; Ok(Json(readlist_dto(&state.db, model, vis.as_ref()).await?)) } @@ -307,7 +298,7 @@ pub async fn get_readlist_books( require_permission!(auth, Permission::ReadListsRead)?; let read_list = get_readlist_or_404(&state, read_list_id).await?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; let members = ReadListRepository::get_books( &state.db, &read_list, @@ -360,7 +351,7 @@ pub async fn add_readlist_books( .map_err(internal("Failed to add book to read list"))?; } - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; Ok(Json(readlist_dto(&state.db, model, vis.as_ref()).await?)) } @@ -381,6 +372,7 @@ pub async fn remove_readlist_book( Path((read_list_id, book_id)): Path<(Uuid, Uuid)>, ) -> Result { require_permission!(auth, Permission::ReadListsWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; ReadListRepository::remove_book(&state.db, read_list_id, book_id) .await .map_err(internal("Failed to remove book from read list"))?; @@ -432,7 +424,7 @@ pub async fn get_readlist_thumbnail( ) -> Result { auth.require_permission(&Permission::ReadListsRead)?; let read_list = get_readlist_or_404(&state, read_list_id).await?; - let vis = user_visibility(&state, auth.user_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; let members = ReadListRepository::get_books( &state.db, &read_list, @@ -472,7 +464,8 @@ pub async fn get_book_readlists( Path(book_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::ReadListsRead)?; - let vis = user_visibility(&state, auth.user_id).await?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; + let vis = user_visibility(&state.db, auth.user_id).await?; let read_lists = ReadListRepository::get_read_lists_for_book(&state.db, book_id) .await diff --git a/crates/codex-api/src/routes/v1/handlers/recommendations.rs b/crates/codex-api/src/routes/v1/handlers/recommendations.rs index c0c57c224..8a93075ca 100644 --- a/crates/codex-api/src/routes/v1/handlers/recommendations.rs +++ b/crates/codex-api/src/routes/v1/handlers/recommendations.rs @@ -116,9 +116,10 @@ pub async fn get_recommendations( let mut all_recommendations: Vec = Vec::new(); let mut sources: Vec = Vec::new(); + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; for (plugin, instance) in &instances { let (recs, source) = - collect_instance_recommendations(&state, auth.user_id, plugin, instance).await; + collect_instance_recommendations(&state, auth.user_id, plugin, instance, &filter).await; all_recommendations.extend(recs); sources.push(source); } @@ -141,6 +142,7 @@ async fn collect_instance_recommendations( user_id: Uuid, plugin: &codex_db::entities::plugins::Model, instance: &codex_db::entities::user_plugins::Model, + filter: &codex_services::content_filter::ContentFilter, ) -> (Vec, RecommendationSourceDto) { let source = plugin_source(plugin).unwrap_or_default(); @@ -225,7 +227,7 @@ async fn collect_instance_recommendations( None => (Vec::new(), None, false), }; - enrich_and_filter_codex_presence(&state.db, &mut recommendations, plugin).await; + enrich_and_filter_codex_presence(&state.db, &mut recommendations, plugin, filter).await; let source_dto = RecommendationSourceDto { plugin_id: plugin.id, @@ -406,6 +408,7 @@ async fn enrich_and_filter_codex_presence( db: &sea_orm::DatabaseConnection, recommendations: &mut [RecommendationDto], plugin: &codex_db::entities::plugins::Model, + filter: &codex_services::content_filter::ContentFilter, ) { // Resolve the external_id_source from the plugin manifest let source = plugin @@ -436,7 +439,11 @@ async fn enrich_and_filter_codex_presence( Ok(matches) => { let mut enriched = 0; for rec in recommendations.iter_mut() { - if let Some(ext_id_record) = matches.get(&rec.external_id) { + // A series the user cannot see is reported as not in Codex, so + // a recommendation never reveals that it exists. + if let Some(ext_id_record) = matches.get(&rec.external_id) + && filter.is_series_visible(ext_id_record.series_id) + { rec.in_codex = true; rec.codex_series_id = Some(ext_id_record.series_id.to_string()); enriched += 1; diff --git a/crates/codex-api/src/routes/v1/handlers/releases.rs b/crates/codex-api/src/routes/v1/handlers/releases.rs index cd534da51..6459b4bb1 100644 --- a/crates/codex-api/src/routes/v1/handlers/releases.rs +++ b/crates/codex-api/src/routes/v1/handlers/releases.rs @@ -137,6 +137,7 @@ pub async fn list_series_releases( Query(params): Query, ) -> Result { auth.require_permission(&Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; let series = SeriesRepository::get_by_id(&state.db, series_id) .await @@ -285,6 +286,7 @@ pub async fn list_release_inbox( source_id: params.source_id, language: params.language.clone(), library_id: params.library_id, + visibility: crate::visibility::user_visibility(&state.db, auth.user_id).await?, }; let sort = params .sort @@ -554,6 +556,7 @@ pub async fn list_release_facets( source_id: params.source_id, language: params.language.clone(), library_id: params.library_id, + visibility: crate::visibility::user_visibility(&state.db, auth.user_id).await?, }; // Each facet excludes its own dimension from the filter so it always diff --git a/crates/codex-api/src/routes/v1/handlers/scan.rs b/crates/codex-api/src/routes/v1/handlers/scan.rs index fc5bdb5b5..cf3ea927b 100644 --- a/crates/codex-api/src/routes/v1/handlers/scan.rs +++ b/crates/codex-api/src/routes/v1/handlers/scan.rs @@ -87,6 +87,7 @@ pub async fn trigger_scan( ) -> Result, ApiError> { // Check permission auth.require_permission(&Permission::LibrariesWrite)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; // Check if library exists LibraryRepository::get_by_id(&state.db, library_id) @@ -175,6 +176,7 @@ pub async fn get_scan_status( ) -> Result, ApiError> { // Check permission auth.require_permission(&Permission::LibrariesRead)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; // Find the most recent scan task for this library use codex_db::entities::{prelude::*, tasks}; @@ -256,6 +258,7 @@ pub async fn cancel_scan( ) -> Result { // Check permission auth.require_permission(&Permission::LibrariesWrite)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; // Find the active scan task for this library use codex_db::entities::{prelude::*, tasks}; @@ -370,13 +373,16 @@ pub async fn scan_progress_stream( // Create SSE stream that filters for scan_library tasks only let db = state.db.clone(); + let user_id = auth.user_id; let stream = async_stream::stream! { loop { match receiver.recv().await { Ok(event) => { - // Only emit scan_library task events + // Only emit scan_library task events, for libraries this + // subscriber can see if event.task_type == "scan_library" - && let Some(library_id) = event.library_id { + && let Some(library_id) = event.library_id + && crate::visibility::task_event_visible(&db, user_id, &event).await { let (files_processed, files_total) = if let Some(ref prog) = event.progress { (prog.current, prog.total) } else { @@ -470,6 +476,7 @@ pub async fn trigger_series_analysis( ) -> Result, ApiError> { // Check permission auth.require_permission(&Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists SeriesRepository::get_by_id(&state.db, series_id) @@ -519,6 +526,7 @@ pub async fn trigger_book_analysis( ) -> Result, ApiError> { // Check permission auth.require_permission(&Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists BookRepository::get_by_id(&state.db, book_id) @@ -572,6 +580,7 @@ pub async fn trigger_library_analysis( ) -> Result, ApiError> { // Check permission auth.require_permission(&Permission::LibrariesWrite)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; // Verify library exists LibraryRepository::get_by_id(&state.db, library_id) @@ -673,6 +682,7 @@ pub async fn trigger_library_unanalyzed_analysis( ) -> Result, ApiError> { // Check permission auth.require_permission(&Permission::LibrariesWrite)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; // Verify library exists LibraryRepository::get_by_id(&state.db, library_id) @@ -769,6 +779,7 @@ pub async fn trigger_series_unanalyzed_analysis( ) -> Result, ApiError> { // Check permission auth.require_permission(&Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists SeriesRepository::get_by_id(&state.db, series_id) @@ -865,6 +876,7 @@ pub async fn trigger_book_unanalyzed_analysis( ) -> Result, ApiError> { // Check permission auth.require_permission(&Permission::BooksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists BookRepository::get_by_id(&state.db, book_id) @@ -928,6 +940,7 @@ pub async fn renumber_series( auth: AuthContext, ) -> Result, ApiError> { auth.require_permission(&Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists before enqueuing SeriesRepository::get_by_id(&state.db, series_id) diff --git a/crates/codex-api/src/routes/v1/handlers/series.rs b/crates/codex-api/src/routes/v1/handlers/series.rs index 02d63b325..a7d6a94ec 100644 --- a/crates/codex-api/src/routes/v1/handlers/series.rs +++ b/crates/codex-api/src/routes/v1/handlers/series.rs @@ -951,7 +951,7 @@ async fn list_series_impl( }; // Sharing-tag visibility goes into the repository queries below. - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -1109,7 +1109,7 @@ pub async fn list_series_external_index( let offset = (page - 1) * page_size; // Honor sharing-tag visibility: only export series this user can access. - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -1246,7 +1246,7 @@ pub async fn get_series_full( .map_err(|e| ApiError::Internal(format!("Failed to fetch series: {}", e)))? .ok_or_else(|| ApiError::NotFound("Series not found".to_string()))?; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; @@ -1295,7 +1295,7 @@ pub async fn get_series( .ok_or_else(|| ApiError::NotFound("Series not found".to_string()))?; // Check sharing tag access - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; @@ -1349,6 +1349,7 @@ pub async fn patch_series( use sea_orm::{ActiveModelTrait, Set}; require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists let series_model = SeriesRepository::get_by_id(&state.db, series_id) @@ -1486,7 +1487,7 @@ pub async fn search_series( require_permission!(auth, Permission::SeriesRead)?; // Apply sharing tag content filter - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -1627,7 +1628,7 @@ pub async fn list_series_filtered( let use_relevance_order = sort.field == SeriesSortField::Relevance && has_fuzzy_search_query; // Apply sharing tag content filter - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -1819,7 +1820,7 @@ pub async fn list_series_alphabetical_groups( require_permission!(auth, Permission::SeriesRead)?; // Apply sharing tag content filter at the SQL layer. - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -1919,6 +1920,7 @@ pub async fn get_series_books( Query(query): Query, ) -> Result { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists SeriesRepository::get_by_id(&state.db, series_id) @@ -1966,6 +1968,7 @@ pub async fn purge_series_deleted_books( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -2026,6 +2029,7 @@ pub async fn upload_series_cover( mut multipart: Multipart, ) -> Result { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get its library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -2159,6 +2163,7 @@ pub async fn set_series_cover_source( Json(request): Json, ) -> Result { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -2227,6 +2232,7 @@ pub async fn get_series_thumbnail( Path(series_id): Path, ) -> Result { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // OPTIMIZATION 1: Check disk cache FIRST before hitting the database. // This avoids acquiring a DB connection for cached thumbnails. @@ -2455,7 +2461,7 @@ pub async fn list_in_progress_series( ) -> Result { require_permission!(auth, Permission::SeriesRead)?; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -2542,7 +2548,7 @@ pub async fn list_recently_added_series( ) -> Result { require_permission!(auth, Permission::SeriesRead)?; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -2600,8 +2606,9 @@ pub async fn list_library_recently_added_series( Query(query): Query, ) -> Result { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -2656,7 +2663,7 @@ pub async fn list_recently_updated_series( ) -> Result { require_permission!(auth, Permission::SeriesRead)?; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -2713,8 +2720,9 @@ pub async fn list_library_recently_updated_series( Query(query): Query, ) -> Result { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -2771,6 +2779,7 @@ pub async fn list_library_series( Query(query): Query, ) -> Result { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; // Validate and normalize pagination params (1-indexed) let page = query.page.max(1); @@ -2788,7 +2797,7 @@ pub async fn list_library_series( .unwrap_or_default(); // Load content filter for sharing tags - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -2892,8 +2901,9 @@ pub async fn list_library_in_progress_series( Query(query): Query, ) -> Result { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; - let content_filter = ContentFilter::for_user(&state.db, auth.user_id) + let content_filter = ContentFilter::cached_for_user(&state.db, auth.user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; let visibility = content_filter.to_visibility(); @@ -2957,6 +2967,7 @@ pub async fn mark_series_as_read( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::ProgressWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists let _series = SeriesRepository::get_by_id(&state.db, series_id) @@ -3024,6 +3035,7 @@ pub async fn mark_series_as_unread( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::ProgressWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists let _series = SeriesRepository::get_by_id(&state.db, series_id) @@ -3089,6 +3101,7 @@ pub async fn download_series( Path(series_id): Path, ) -> Result { require_permission!(auth, Permission::BooksRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Fetch series to verify it exists let _series = SeriesRepository::get_by_id(&state.db, series_id) @@ -3232,6 +3245,7 @@ pub async fn replace_series_metadata( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Find the series let existing_series = SeriesRepository::get_by_id(&state.db, series_id) @@ -3385,6 +3399,7 @@ pub async fn reset_series_metadata( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -3588,6 +3603,7 @@ pub async fn patch_series_metadata( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Find the series let existing_series = SeriesRepository::get_by_id(&state.db, series_id) @@ -3811,6 +3827,7 @@ pub async fn get_series_metadata( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists let _series = SeriesRepository::get_by_id(&state.db, series_id) @@ -4002,6 +4019,7 @@ pub async fn update_metadata_locks( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists SeriesRepository::get_by_id(&state.db, series_id) @@ -4153,6 +4171,7 @@ pub async fn get_metadata_locks( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists SeriesRepository::get_by_id(&state.db, series_id) @@ -4253,9 +4272,19 @@ pub async fn list_genres( let page = params.page.max(1); let page_size = params.page_size.clamp(1, MAX_PAGE_SIZE); - let genres = GenreRepository::list_all(&state.db) + // Counts only include series the user can see, and a restricted user + // only sees genres carried by at least one such series. + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; + let visibility = filter.to_visibility(); + let counts = GenreRepository::series_counts(&state.db, visibility.as_ref()) .await - .map_err(|e| ApiError::Internal(format!("Failed to fetch genres: {}", e)))?; + .map_err(|e| ApiError::Internal(format!("Failed to count genres: {}", e)))?; + let genres: Vec<_> = GenreRepository::list_all(&state.db) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch genres: {}", e)))? + .into_iter() + .filter(|g| !filter.has_restrictions || counts.contains_key(&g.id)) + .collect(); let total = genres.len() as u64; let total_pages = if page_size == 0 { @@ -4274,9 +4303,7 @@ pub async fn list_genres( let mut dtos: Vec = Vec::with_capacity(paginated_genres.len()); for g in paginated_genres { - let count = GenreRepository::count_series_with_genre(&state.db, g.id) - .await - .ok(); + let count = Some(counts.get(&g.id).copied().unwrap_or(0)); dtos.push(GenreDto { id: g.id, name: g.name, @@ -4317,6 +4344,7 @@ pub async fn get_series_genres( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists SeriesRepository::get_by_id(&state.db, series_id) @@ -4367,6 +4395,7 @@ pub async fn set_series_genres( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -4455,9 +4484,19 @@ pub async fn list_tags( let page = params.page.max(1); let page_size = params.page_size.clamp(1, MAX_PAGE_SIZE); - let tags = TagRepository::list_all(&state.db) + // Counts only include series the user can see, and a restricted user + // only sees tags carried by at least one such series. + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; + let visibility = filter.to_visibility(); + let counts = TagRepository::series_counts(&state.db, visibility.as_ref()) .await - .map_err(|e| ApiError::Internal(format!("Failed to fetch tags: {}", e)))?; + .map_err(|e| ApiError::Internal(format!("Failed to count tags: {}", e)))?; + let tags: Vec<_> = TagRepository::list_all(&state.db) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch tags: {}", e)))? + .into_iter() + .filter(|g| !filter.has_restrictions || counts.contains_key(&g.id)) + .collect(); let total = tags.len() as u64; let total_pages = if page_size == 0 { @@ -4476,9 +4515,7 @@ pub async fn list_tags( let mut dtos: Vec = Vec::with_capacity(paginated_tags.len()); for t in paginated_tags { - let count = TagRepository::count_series_with_tag(&state.db, t.id) - .await - .ok(); + let count = Some(counts.get(&t.id).copied().unwrap_or(0)); dtos.push(TagDto { id: t.id, name: t.name, @@ -4519,6 +4556,7 @@ pub async fn get_series_tags( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists SeriesRepository::get_by_id(&state.db, series_id) @@ -4569,6 +4607,7 @@ pub async fn set_series_tags( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -4631,6 +4670,7 @@ pub async fn add_series_genre( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -4687,6 +4727,7 @@ pub async fn remove_series_genre( Path((series_id, genre_id)): Path<(Uuid, Uuid)>, ) -> Result { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -4811,6 +4852,7 @@ pub async fn add_series_tag( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -4867,6 +4909,7 @@ pub async fn remove_series_tag( Path((series_id, tag_id)): Path<(Uuid, Uuid)>, ) -> Result { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -4997,6 +5040,7 @@ pub async fn get_series_rating( Path(series_id): Path, ) -> Result { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists SeriesRepository::get_by_id(&state.db, series_id) @@ -5052,6 +5096,7 @@ pub async fn set_series_rating( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Validate rating range if !(1..=100).contains(&request.rating) { @@ -5110,6 +5155,7 @@ pub async fn delete_series_rating( Path(series_id): Path, ) -> Result { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists SeriesRepository::get_by_id(&state.db, series_id) @@ -5198,6 +5244,7 @@ pub async fn get_series_alternate_titles( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists SeriesRepository::get_by_id(&state.db, series_id) @@ -5250,6 +5297,7 @@ pub async fn create_alternate_title( Json(request): Json, ) -> Result<(StatusCode, Json), ApiError> { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -5321,6 +5369,7 @@ pub async fn update_alternate_title( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -5395,6 +5444,7 @@ pub async fn delete_alternate_title( Path((series_id, title_id)): Path<(Uuid, Uuid)>, ) -> Result { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -5462,6 +5512,7 @@ pub async fn get_series_external_ratings( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists SeriesRepository::get_by_id(&state.db, series_id) @@ -5516,6 +5567,7 @@ pub async fn create_external_rating( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -5586,6 +5638,7 @@ pub async fn delete_external_rating( Path((series_id, source)): Path<(Uuid, String)>, ) -> Result { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -5648,6 +5701,7 @@ pub async fn get_series_average_rating( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists SeriesRepository::get_by_id(&state.db, series_id) @@ -5696,6 +5750,7 @@ pub async fn get_series_external_links( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists SeriesRepository::get_by_id(&state.db, series_id) @@ -5749,6 +5804,7 @@ pub async fn create_external_link( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -5814,6 +5870,7 @@ pub async fn delete_external_link( Path((series_id, link_id)): Path<(Uuid, Uuid)>, ) -> Result { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -5877,6 +5934,7 @@ pub async fn list_series_external_ids( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists SeriesRepository::get_by_id(&state.db, series_id) @@ -5922,6 +5980,7 @@ pub async fn create_series_external_id( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -5980,6 +6039,7 @@ pub async fn delete_series_external_id( Path((series_id, external_id_id)): Path<(Uuid, Uuid)>, ) -> Result { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -6044,6 +6104,7 @@ pub async fn list_series_covers( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists SeriesRepository::get_by_id(&state.db, series_id) @@ -6099,6 +6160,7 @@ pub async fn select_series_cover( Path((series_id, cover_id)): Path<(Uuid, Uuid)>, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -6179,6 +6241,7 @@ pub async fn reset_series_cover( Path(series_id): Path, ) -> Result { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -6240,6 +6303,7 @@ pub async fn delete_series_cover( Path((series_id, cover_id)): Path<(Uuid, Uuid)>, ) -> Result { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists and get library_id let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -6344,6 +6408,7 @@ pub async fn get_series_cover_image( Path((series_id, cover_id)): Path<(Uuid, Uuid)>, ) -> Result { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists let _series = SeriesRepository::get_by_id(&state.db, series_id) diff --git a/crates/codex-api/src/routes/v1/handlers/task_metrics.rs b/crates/codex-api/src/routes/v1/handlers/task_metrics.rs index 93048a014..b0e863cec 100644 --- a/crates/codex-api/src/routes/v1/handlers/task_metrics.rs +++ b/crates/codex-api/src/routes/v1/handlers/task_metrics.rs @@ -24,7 +24,7 @@ use codex_db::repositories::TaskRepository; path = "/api/v1/metrics/tasks", responses( (status = 200, description = "Task metrics retrieved successfully", body = TaskMetricsResponse), - (status = 403, description = "Permission denied"), + (status = 403, description = "Requires tasks:read (admins and maintainers)"), (status = 503, description = "Task metrics service not available"), ), security( @@ -37,7 +37,7 @@ pub async fn get_task_metrics( State(state): State>, auth: AuthContext, ) -> Result, ApiError> { - auth.require_permission(&Permission::LibrariesRead)?; + auth.require_permission(&Permission::TasksRead)?; let metrics_service = state .task_metrics_service @@ -118,7 +118,7 @@ pub async fn get_task_metrics( params(TaskMetricsHistoryQuery), responses( (status = 200, description = "Task metrics history retrieved successfully", body = TaskMetricsHistoryResponse), - (status = 403, description = "Permission denied"), + (status = 403, description = "Requires tasks:read (admins and maintainers)"), (status = 503, description = "Task metrics service not available"), ), security( @@ -132,7 +132,7 @@ pub async fn get_task_metrics_history( auth: AuthContext, Query(query): Query, ) -> Result, ApiError> { - auth.require_permission(&Permission::LibrariesRead)?; + auth.require_permission(&Permission::TasksRead)?; let metrics_service = state .task_metrics_service diff --git a/crates/codex-api/src/routes/v1/handlers/task_queue.rs b/crates/codex-api/src/routes/v1/handlers/task_queue.rs index 6945448f5..16ea521e1 100644 --- a/crates/codex-api/src/routes/v1/handlers/task_queue.rs +++ b/crates/codex-api/src/routes/v1/handlers/task_queue.rs @@ -756,6 +756,7 @@ pub async fn generate_library_book_thumbnails( // Check permission auth.require_permission(&Permission::TasksWrite)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; // Verify library exists LibraryRepository::get_by_id(&state.db, library_id) @@ -812,6 +813,7 @@ pub async fn generate_book_thumbnail( // Check permission auth.require_permission(&Permission::TasksWrite)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; // Verify book exists BookRepository::get_by_id(&state.db, book_id) @@ -866,6 +868,7 @@ pub async fn generate_series_thumbnail( // Check permission auth.require_permission(&Permission::TasksWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists SeriesRepository::get_by_id(&state.db, series_id) @@ -989,6 +992,7 @@ pub async fn generate_library_series_thumbnails( ) -> Result, ApiError> { // Check permission auth.require_permission(&Permission::TasksWrite)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; // Verify library exists LibraryRepository::get_by_id(&state.db, library_id) @@ -1063,6 +1067,7 @@ pub async fn reprocess_series_title( use codex_services::metadata::preprocessing::apply_rules; auth.require_permission(&Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; // Verify series exists let series = SeriesRepository::get_by_id(&state.db, series_id) @@ -1188,6 +1193,7 @@ pub async fn reprocess_library_series_titles( use codex_services::metadata::preprocessing::apply_rules; auth.require_permission(&Permission::LibrariesWrite)?; + crate::visibility::ensure_library_visible(&state.db, auth.user_id, library_id).await?; // Fetch the library let library = LibraryRepository::get_by_id(&state.db, library_id) diff --git a/crates/codex-api/src/routes/v1/handlers/tracking.rs b/crates/codex-api/src/routes/v1/handlers/tracking.rs index b50148e66..31ac7286d 100644 --- a/crates/codex-api/src/routes/v1/handlers/tracking.rs +++ b/crates/codex-api/src/routes/v1/handlers/tracking.rs @@ -64,6 +64,7 @@ pub async fn get_series_tracking( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; SeriesRepository::get_by_id(&state.db, series_id) .await @@ -106,6 +107,7 @@ pub async fn update_series_tracking( Json(request): Json, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; let series = SeriesRepository::get_by_id(&state.db, series_id) .await @@ -195,6 +197,7 @@ pub async fn list_series_aliases( Path(series_id): Path, ) -> Result, ApiError> { require_permission!(auth, Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; SeriesRepository::get_by_id(&state.db, series_id) .await @@ -241,6 +244,7 @@ pub async fn create_series_alias( Json(request): Json, ) -> Result<(StatusCode, Json), ApiError> { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; let series = SeriesRepository::get_by_id(&state.db, series_id) .await @@ -323,6 +327,7 @@ pub async fn delete_series_alias( Path((series_id, alias_id)): Path<(Uuid, Uuid)>, ) -> Result { require_permission!(auth, Permission::SeriesWrite)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; let series = SeriesRepository::get_by_id(&state.db, series_id) .await diff --git a/crates/codex-api/src/routes/v1/handlers/user_series_reader_settings.rs b/crates/codex-api/src/routes/v1/handlers/user_series_reader_settings.rs index be37ba40e..b9ae84572 100644 --- a/crates/codex-api/src/routes/v1/handlers/user_series_reader_settings.rs +++ b/crates/codex-api/src/routes/v1/handlers/user_series_reader_settings.rs @@ -133,6 +133,7 @@ pub async fn get_series_reader_settings( auth: AuthContext, Path(series_id): Path, ) -> Result, ApiError> { + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; let series = require_series(&state, series_id).await?; let settings = @@ -184,6 +185,7 @@ pub async fn patch_series_reader_settings( Path(series_id): Path, Json(request): Json, ) -> Result, ApiError> { + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; let series = require_series(&state, series_id).await?; let current = @@ -238,6 +240,7 @@ pub async fn delete_series_reader_settings( auth: AuthContext, Path(series_id): Path, ) -> Result { + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; require_series(&state, series_id).await?; UserSeriesReaderSettingsRepository::delete(&state.db, auth.user_id, series_id) diff --git a/crates/codex-api/src/routes/v1/handlers/users.rs b/crates/codex-api/src/routes/v1/handlers/users.rs index 3c999728b..a812e699b 100644 --- a/crates/codex-api/src/routes/v1/handlers/users.rs +++ b/crates/codex-api/src/routes/v1/handlers/users.rs @@ -1,6 +1,6 @@ use super::super::dto::{ - CreateUserRequest, PaginatedResponse, UpdateUserRequest, UserDetailDto, UserDto, - UserListParams, UserSharingTagGrantDto, common::PaginationLinkBuilder, + CreateUserRequest, PaginatedResponse, UpdateUserRequest, UserAccessGroupDto, UserDetailDto, + UserDto, UserListParams, UserSharingTagGrantDto, common::PaginationLinkBuilder, }; use super::paginated_response; use crate::require_permission; @@ -16,8 +16,11 @@ use axum::{ }; use chrono::Utc; use codex_db::entities::users; -use codex_db::repositories::{SharingTagRepository, UserListFilter, UserRepository}; +use codex_db::repositories::{ + AccessGroupRepository, SharingTagRepository, UserListFilter, UserRepository, +}; use codex_utils::password; +use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; @@ -121,23 +124,14 @@ pub async fn list_users( .await .map_err(|e| ApiError::Internal(format!("Failed to fetch users: {}", e)))?; + let user_ids: Vec = result.users.iter().map(|u| u.id).collect(); + let mut groups = access_groups_by_user(&state.db, &user_ids).await?; let dtos: Vec = result .users .into_iter() .map(|user| { - let role = user.get_role(); - let permissions = parse_permissions_json(&user.permissions); - UserDto { - id: user.id, - username: user.username, - email: user.email, - role, - permissions, - is_active: user.is_active, - last_login_at: user.last_login_at, - created_at: user.created_at, - updated_at: user.updated_at, - } + let user_groups = groups.remove(&user.id).unwrap_or_default(); + user_to_dto(user, user_groups) }) .collect(); @@ -287,19 +281,11 @@ pub async fn create_user( .await .map_err(|e| ApiError::Internal(format!("Failed to create user: {}", e)))?; - let role = user.get_role(); - let permissions = parse_permissions_json(&user.permissions); - let dto = UserDto { - id: user.id, - username: user.username, - email: user.email, - role, - permissions, - is_active: user.is_active, - last_login_at: user.last_login_at, - created_at: user.created_at, - updated_at: user.updated_at, - }; + let user_groups = access_groups_by_user(&state.db, &[user.id]) + .await? + .remove(&user.id) + .unwrap_or_default(); + let dto = user_to_dto(user, user_groups); Ok(Json(dto)) } @@ -392,19 +378,11 @@ pub async fn update_user( .await .map_err(|e| ApiError::Internal(format!("Failed to update user: {}", e)))?; - let role = updated.get_role(); - let permissions = parse_permissions_json(&updated.permissions); - let dto = UserDto { - id: updated.id, - username: updated.username, - email: updated.email, - role, - permissions, - is_active: updated.is_active, - last_login_at: updated.last_login_at, - created_at: updated.created_at, - updated_at: updated.updated_at, - }; + let user_groups = access_groups_by_user(&state.db, &[updated.id]) + .await? + .remove(&updated.id) + .unwrap_or_default(); + let dto = user_to_dto(updated, user_groups); Ok(Json(dto)) } @@ -447,3 +425,45 @@ pub async fn delete_user( Ok(()) } + +/// Build the list-level user DTO from a user row and their group memberships. +fn user_to_dto(user: users::Model, access_groups: Vec) -> UserDto { + let role = user.get_role(); + let permissions = parse_permissions_json(&user.permissions); + UserDto { + id: user.id, + username: user.username, + email: user.email, + role, + permissions, + is_active: user.is_active, + last_login_at: user.last_login_at, + created_at: user.created_at, + updated_at: user.updated_at, + access_groups, + } +} + +/// Access group memberships for a batch of users, keyed by user ID. +async fn access_groups_by_user( + db: &sea_orm::DatabaseConnection, + user_ids: &[Uuid], +) -> Result>, ApiError> { + let memberships = AccessGroupRepository::list_memberships_for_users(db, user_ids) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch access groups: {}", e)))?; + Ok(memberships + .into_iter() + .map(|(user_id, entries)| { + let dtos = entries + .into_iter() + .map(|(group, membership)| UserAccessGroupDto { + id: group.id, + name: group.name, + source: membership.source, + }) + .collect(); + (user_id, dtos) + }) + .collect()) +} diff --git a/crates/codex-api/src/routes/v1/handlers/want_to_read.rs b/crates/codex-api/src/routes/v1/handlers/want_to_read.rs index e6cb48b71..d5294d24f 100644 --- a/crates/codex-api/src/routes/v1/handlers/want_to_read.rs +++ b/crates/codex-api/src/routes/v1/handlers/want_to_read.rs @@ -107,19 +107,13 @@ pub async fn add_want_to_read( let entry = match (request.series_id, request.book_id) { (Some(series_id), None) => { - SeriesRepository::get_by_id(&state.db, series_id) - .await - .map_err(|e| ApiError::Internal(format!("Failed to look up series: {e}")))? - .ok_or_else(|| ApiError::NotFound("Series not found".to_string()))?; + crate::visibility::require_visible_series(&state.db, auth.user_id, series_id).await?; WantToReadRepository::add_series(&state.db, auth.user_id, series_id) .await .map_err(|e| ApiError::Internal(format!("Failed to add series: {e}")))? } (None, Some(book_id)) => { - BookRepository::get_by_id(&state.db, book_id) - .await - .map_err(|e| ApiError::Internal(format!("Failed to look up book: {e}")))? - .ok_or_else(|| ApiError::NotFound("Book not found".to_string()))?; + crate::visibility::require_visible_book(&state.db, auth.user_id, book_id).await?; WantToReadRepository::add_book(&state.db, auth.user_id, book_id) .await .map_err(|e| ApiError::Internal(format!("Failed to add book: {e}")))? @@ -164,6 +158,8 @@ pub async fn bulk_add_want_to_read( let mut added = 0; let mut already_present = 0; + // Ids the user cannot see are skipped like ids that do not exist. + let filter = crate::visibility::content_filter(&state.db, auth.user_id).await?; if !request.series_ids.is_empty() { // Filter to series that actually exist so phantom IDs don't violate the @@ -177,7 +173,7 @@ pub async fn bulk_add_want_to_read( .series_ids .iter() .copied() - .filter(|id| existing.contains(id) && seen.insert(*id)) + .filter(|id| existing.contains(id) && filter.is_series_visible(*id) && seen.insert(*id)) .collect(); let inserted = WantToReadRepository::add_series_bulk(&state.db, auth.user_id, &valid) .await @@ -187,9 +183,14 @@ pub async fn bulk_add_want_to_read( } if !request.book_ids.is_empty() { - let existing = BookRepository::get_existing_ids(&state.db, &request.book_ids) - .await - .map_err(|e| ApiError::Internal(format!("Failed to look up books: {e}")))?; + let existing: std::collections::HashSet = + BookRepository::get_by_ids(&state.db, &request.book_ids) + .await + .map_err(|e| ApiError::Internal(format!("Failed to look up books: {e}")))? + .into_iter() + .filter(|book| filter.is_book_visible(book.series_id)) + .map(|book| book.id) + .collect(); let mut seen = std::collections::HashSet::new(); let valid: Vec = request .book_ids @@ -262,6 +263,7 @@ pub async fn remove_want_to_read_series( Path(series_id): Path, ) -> Result { auth.require_permission(&Permission::SeriesRead)?; + crate::visibility::ensure_series_visible(&state.db, auth.user_id, series_id).await?; WantToReadRepository::remove_series(&state.db, auth.user_id, series_id) .await @@ -289,6 +291,7 @@ pub async fn remove_want_to_read_book( Path(book_id): Path, ) -> Result { auth.require_permission(&Permission::SeriesRead)?; + crate::visibility::ensure_book_id_visible(&state.db, auth.user_id, book_id).await?; WantToReadRepository::remove_book(&state.db, auth.user_id, book_id) .await diff --git a/crates/codex-api/src/routes/v1/routes/admin.rs b/crates/codex-api/src/routes/v1/routes/admin.rs index e96e4fbc3..03580001b 100644 --- a/crates/codex-api/src/routes/v1/routes/admin.rs +++ b/crates/codex-api/src/routes/v1/routes/admin.rs @@ -156,10 +156,19 @@ pub fn routes(_state: Arc) -> Router> { "/access-groups/{group_id}/members", post(handlers::access_groups::add_access_group_members), ) + .route( + "/access-groups/{group_id}/add-all-users", + post(handlers::access_groups::add_all_users_to_access_group), + ) .route( "/access-groups/{group_id}/members/{user_id}", delete(handlers::access_groups::remove_access_group_member), ) + .route( + "/access-groups/{group_id}/rules", + get(handlers::access_groups::get_access_group_rules) + .put(handlers::access_groups::replace_access_group_rules), + ) .route( "/access-groups/{group_id}/grants", post(handlers::access_groups::add_access_group_grant), diff --git a/crates/codex-api/src/routes/v1/routes/users.rs b/crates/codex-api/src/routes/v1/routes/users.rs index df21d2cea..42324c005 100644 --- a/crates/codex-api/src/routes/v1/routes/users.rs +++ b/crates/codex-api/src/routes/v1/routes/users.rs @@ -48,6 +48,10 @@ pub fn routes(_state: Arc) -> Router> { get(handlers::access_groups::get_user_access_groups), ) // Effective grants debug endpoint (admin only) + .route( + "/users/{user_id}/visibility-preview", + get(handlers::access_groups::get_visibility_preview), + ) .route( "/users/{user_id}/effective-grants", get(handlers::access_groups::get_user_effective_grants), diff --git a/crates/codex-api/src/visibility.rs b/crates/codex-api/src/visibility.rs new file mode 100644 index 000000000..738801e92 --- /dev/null +++ b/crates/codex-api/src/visibility.rs @@ -0,0 +1,334 @@ +//! Content visibility for handlers. +//! +//! Every handler that returns, streams or changes something tied to a series +//! or a book goes through these helpers, so what a restricted reader can see +//! is decided in one place. A hidden item answers exactly like a missing one +//! (404), so its existence cannot be probed. +//! +//! The filter comes from the per-process cache (see +//! `codex_services::content_filter_cache`), which matters on hot paths such as +//! page images. + +use crate::error::ApiError; +use codex_db::entities::{books, series}; +use codex_db::repositories::{BookRepository, SeriesRepository, SeriesVisibility}; +use codex_services::content_filter::ContentFilter; +use sea_orm::DatabaseConnection; +use std::sync::Arc; +use uuid::Uuid; + +/// The user's content filter. +pub async fn content_filter( + db: &DatabaseConnection, + user_id: Uuid, +) -> Result, ApiError> { + ContentFilter::cached_for_user(db, user_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e))) +} + +/// The SQL-level visibility constraint for the user, or `None` when they see +/// everything. +pub async fn user_visibility( + db: &DatabaseConnection, + user_id: Uuid, +) -> Result, ApiError> { + Ok(content_filter(db, user_id).await?.to_visibility()) +} + +/// Fail with 404 unless the series exists and the user can see it. +pub async fn require_visible_series( + db: &DatabaseConnection, + user_id: Uuid, + series_id: Uuid, +) -> Result { + let series = SeriesRepository::get_by_id(db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch series: {}", e)))? + .ok_or_else(series_not_found)?; + if !content_filter(db, user_id) + .await? + .is_series_visible(series.id) + { + return Err(series_not_found()); + } + Ok(series) +} + +/// Fail with 404 unless the book exists and the user can see its series. +pub async fn require_visible_book( + db: &DatabaseConnection, + user_id: Uuid, + book_id: Uuid, +) -> Result { + let book = BookRepository::get_by_id(db, book_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch book: {}", e)))? + .ok_or_else(book_not_found)?; + if !content_filter(db, user_id) + .await? + .is_book_visible(book.series_id) + { + return Err(book_not_found()); + } + Ok(book) +} + +/// Fail with 404 unless the user can see the book, when the handler does not +/// need the book itself. +/// +/// For a user with no restrictions this runs no query at all (the filter is +/// cached), which matters on paths that are meant to stay off the database, +/// such as thumbnails served from the disk cache. A book that does not exist +/// is left for the handler to report. +pub async fn ensure_book_id_visible( + db: &DatabaseConnection, + user_id: Uuid, + book_id: Uuid, +) -> Result<(), ApiError> { + let filter = content_filter(db, user_id).await?; + if !filter.has_restrictions { + return Ok(()); + } + let book = BookRepository::get_by_id(db, book_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch book: {}", e)))?; + match book { + Some(book) if !filter.is_book_visible(book.series_id) => Err(book_not_found()), + _ => Ok(()), + } +} + +/// Fail with 404 unless the user can see this already-loaded book. +pub async fn ensure_book_visible( + db: &DatabaseConnection, + user_id: Uuid, + book: &books::Model, +) -> Result<(), ApiError> { + if content_filter(db, user_id) + .await? + .is_book_visible(book.series_id) + { + Ok(()) + } else { + Err(book_not_found()) + } +} + +/// Fail with 404 unless the user can see this series id (without loading it). +pub async fn ensure_series_visible( + db: &DatabaseConnection, + user_id: Uuid, + series_id: Uuid, +) -> Result<(), ApiError> { + if content_filter(db, user_id) + .await? + .is_series_visible(series_id) + { + Ok(()) + } else { + Err(series_not_found()) + } +} + +/// Fail with 404 unless the user can see this library. +pub async fn ensure_library_visible( + db: &DatabaseConnection, + user_id: Uuid, + library_id: Uuid, +) -> Result<(), ApiError> { + if content_filter(db, user_id) + .await? + .is_library_visible(library_id) + { + Ok(()) + } else { + Err(ApiError::NotFound("Library not found".to_string())) + } +} + +/// Whether an entity change event may be sent to this user. +/// +/// An event about a series, book or library the user cannot see is withheld, +/// so the event streams never announce hidden content. Fails closed: if +/// visibility cannot be determined, the event is withheld. +pub async fn entity_event_visible( + db: &DatabaseConnection, + user_id: Uuid, + event: &codex_events::EntityChangeEvent, +) -> bool { + let Ok(filter) = content_filter(db, user_id).await else { + return false; + }; + if !filter.has_restrictions { + return true; + } + content_visible( + db, + &filter, + event.library_id(), + event.series_id(), + event.book_id(), + ) + .await +} + +/// Whether a task progress event may be sent to this user. Same rules as +/// [`entity_event_visible`]. +pub async fn task_event_visible( + db: &DatabaseConnection, + user_id: Uuid, + event: &codex_events::TaskProgressEvent, +) -> bool { + let Ok(filter) = content_filter(db, user_id).await else { + return false; + }; + if !filter.has_restrictions { + return true; + } + content_visible( + db, + &filter, + event.library_id, + event.series_id, + event.book_id, + ) + .await +} + +async fn content_visible( + db: &DatabaseConnection, + filter: &ContentFilter, + library_id: Option, + series_id: Option, + book_id: Option, +) -> bool { + if let Some(library_id) = library_id + && !filter.is_library_visible(library_id) + { + return false; + } + if let Some(series_id) = series_id { + return filter.is_series_visible(series_id); + } + if let Some(book_id) = book_id { + // Only book cover events name a book without its series. + return match BookRepository::get_by_id(db, book_id).await { + Ok(Some(book)) => filter.is_book_visible(book.series_id), + _ => false, + }; + } + true +} + +fn series_not_found() -> ApiError { + ApiError::NotFound("Series not found".to_string()) +} + +fn book_not_found() -> ApiError { + ApiError::NotFound("Book not found".to_string()) +} + +#[cfg(test)] +mod guard { + //! Keeps the rule "every handler that takes a series, book or library id + //! checks visibility" true as handlers are added. Visibility used to be a + //! per-handler convention that most handlers forgot; this test is what + //! stops that from coming back. + + use std::path::{Path, PathBuf}; + + /// Names that, appearing in a handler body, mean it enforces visibility. + const CHECKS: &[&str] = &[ + "ContentFilter", + "visibility::", + "require_visible_", + "ensure_series_visible", + "ensure_book_visible", + "ensure_book_id_visible", + "ensure_library_visible", + "user_visibility", + "to_visibility", + "find_book_by_hash", + ]; + + /// Path parameters that name content. + const CONTENT_IDS: &[&str] = &["series_id", "book_id", "library_id"]; + + fn rust_files(dir: &Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + rust_files(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } + } + + /// The names bound by the handler's `Path(...)` extractor, if any. + fn path_params(signature: &str) -> Vec { + let Some(start) = signature.find("Path(") else { + return Vec::new(); + }; + let rest = &signature[start + "Path(".len()..]; + let Some(end) = rest.find("):") else { + return Vec::new(); + }; + rest[..end] + .trim_start_matches('(') + .trim_end_matches(')') + .split(',') + .map(|n| n.trim().to_string()) + .collect() + } + + #[test] + fn every_content_id_handler_checks_visibility() { + let routes = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/routes"); + let mut files = Vec::new(); + rust_files(&routes, &mut files); + + let mut missing = Vec::new(); + for file in files { + let source = std::fs::read_to_string(&file).unwrap(); + let mut rest = source.as_str(); + while let Some(at) = rest.find("pub async fn ") { + let from = &rest[at..]; + let Some(body_start) = from.find("{\n") else { + break; + }; + let body_end = from[body_start..] + .find("\n}\n") + .map(|e| body_start + e) + .unwrap_or(from.len()); + let signature = &from[..body_start]; + let body = &from[body_start..body_end]; + let name: String = from["pub async fn ".len()..] + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + + let takes_content_id = path_params(signature) + .iter() + .any(|p| CONTENT_IDS.contains(&p.as_str())); + // Admins are never restricted, so admin-only handlers are exempt. + let admin_only = body.contains("Permission::SystemAdmin"); + let checks = CHECKS.iter().any(|c| body.contains(c)); + if takes_content_id && !admin_only && !checks { + let relative = file.strip_prefix(&routes).unwrap_or(&file); + missing.push(format!("{}: {}", relative.display(), name)); + } + rest = &from[body_end.min(from.len())..]; + } + } + + assert!( + missing.is_empty(), + "These handlers take a series, book or library id but never check that the user \ + can see it. Call crate::visibility::ensure_series_visible / \ + ensure_book_id_visible / ensure_library_visible first:\n{}", + missing.join("\n") + ); + } +} diff --git a/crates/codex-db/src/access_control_version.rs b/crates/codex-db/src/access_control_version.rs new file mode 100644 index 000000000..0c550731a --- /dev/null +++ b/crates/codex-db/src/access_control_version.rs @@ -0,0 +1,41 @@ +//! A process-wide counter of changes to who can see what. +//! +//! The content filter is cached per user (see `codex-services`). Every +//! repository write that can change a user's visibility (access group rules +//! and memberships, sharing-tag grants, series sharing tags, a user's role, +//! deleting a user) bumps this counter, and a cached filter built under an +//! older value is discarded. Bumping in the repositories rather than in the +//! HTTP handlers means every write path is covered: API calls, the OIDC +//! membership sync on login, joining the default group at account creation. +//! +//! The counter is per process. Another server process sees the change when +//! its cached entry expires. +//! +//! Genre and tag changes on series do not bump it: scans make them +//! constantly, and the cache's short expiry covers them. + +use std::sync::atomic::{AtomicU64, Ordering}; + +static VERSION: AtomicU64 = AtomicU64::new(0); + +/// The current version. A cached filter is valid only if it was built under it. +pub fn current() -> u64 { + VERSION.load(Ordering::Acquire) +} + +/// Record that visibility may have changed for some user. +pub fn bump() { + VERSION.fetch_add(1, Ordering::AcqRel); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bump_moves_the_version_forward() { + let before = current(); + bump(); + assert!(current() > before); + } +} diff --git a/crates/codex-db/src/entities/access_group_genre_rules.rs b/crates/codex-db/src/entities/access_group_genre_rules.rs new file mode 100644 index 000000000..82f20ac04 --- /dev/null +++ b/crates/codex-db/src/entities/access_group_genre_rules.rs @@ -0,0 +1,52 @@ +//! `SeaORM` Entity for access_group_genre_rules table +//! +//! Per-group allow/deny rules on genres, matched by normalized name rather +//! than by id: unused genres are cleaned up and recreated under new ids, and +//! a rule must outlive that. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::user_sharing_tags::AccessMode; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "access_group_genre_rules")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub access_group_id: Uuid, + /// Display name, as the admin picked it + pub name: String, + /// Lowercased, trimmed name used for matching + pub normalized_name: String, + pub access_mode: String, + pub created_at: DateTime, +} + +impl Model { + pub fn get_access_mode(&self) -> AccessMode { + self.access_mode.parse().unwrap_or(AccessMode::Allow) + } +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::access_groups::Entity", + from = "Column::AccessGroupId", + to = "super::access_groups::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + AccessGroup, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::AccessGroup.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/codex-db/src/entities/access_group_library_rules.rs b/crates/codex-db/src/entities/access_group_library_rules.rs new file mode 100644 index 000000000..01412172c --- /dev/null +++ b/crates/codex-db/src/entities/access_group_library_rules.rs @@ -0,0 +1,61 @@ +//! `SeaORM` Entity for access_group_library_rules table +//! +//! Per-group allow/deny rules on libraries. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::user_sharing_tags::AccessMode; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "access_group_library_rules")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub access_group_id: Uuid, + pub library_id: Uuid, + pub access_mode: String, + pub created_at: DateTime, +} + +impl Model { + pub fn get_access_mode(&self) -> AccessMode { + self.access_mode.parse().unwrap_or(AccessMode::Allow) + } +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::access_groups::Entity", + from = "Column::AccessGroupId", + to = "super::access_groups::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + AccessGroup, + #[sea_orm( + belongs_to = "super::libraries::Entity", + from = "Column::LibraryId", + to = "super::libraries::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Library, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::AccessGroup.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Library.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/codex-db/src/entities/access_group_tag_rules.rs b/crates/codex-db/src/entities/access_group_tag_rules.rs new file mode 100644 index 000000000..1a69269dc --- /dev/null +++ b/crates/codex-db/src/entities/access_group_tag_rules.rs @@ -0,0 +1,52 @@ +//! `SeaORM` Entity for access_group_tag_rules table +//! +//! Per-group allow/deny rules on tags, matched by normalized name rather +//! than by id: unused tags are cleaned up and recreated under new ids, and +//! a rule must outlive that. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::user_sharing_tags::AccessMode; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "access_group_tag_rules")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub access_group_id: Uuid, + /// Display name, as the admin picked it + pub name: String, + /// Lowercased, trimmed name used for matching + pub normalized_name: String, + pub access_mode: String, + pub created_at: DateTime, +} + +impl Model { + pub fn get_access_mode(&self) -> AccessMode { + self.access_mode.parse().unwrap_or(AccessMode::Allow) + } +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::access_groups::Entity", + from = "Column::AccessGroupId", + to = "super::access_groups::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + AccessGroup, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::AccessGroup.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/codex-db/src/entities/access_groups.rs b/crates/codex-db/src/entities/access_groups.rs index 1a4d03e5f..dac3c8f92 100644 --- a/crates/codex-db/src/entities/access_groups.rs +++ b/crates/codex-db/src/entities/access_groups.rs @@ -1,8 +1,8 @@ //! `SeaORM` Entity for access_groups table //! -//! Access groups bundle a set of sharing-tag allow/deny rules that can be -//! assigned to multiple users. Per-user grants in `user_sharing_tags` act -//! as overrides on top of group rules. +//! Access groups bundle allow/deny rules on sharing tags, tags, genres and +//! libraries, and are assigned to many users. A user's personal grants in +//! `user_sharing_tags` count as one more group. use chrono::{DateTime, Utc}; use sea_orm::entity::prelude::*; @@ -17,6 +17,15 @@ pub struct Model { #[sea_orm(unique)] pub name: String, pub description: Option, + /// New users join the default group when their account is created. + /// At most one group has this set (partial unique index). + pub is_default: bool, + /// How allowed sharing tags combine: "any" (default) or "all" + pub sharing_tag_match: String, + /// How allowed tags combine: "any" (default) or "all" + pub tag_match: String, + /// How allowed genres combine: "any" (default) or "all" + pub genre_match: String, pub created_at: DateTime, pub updated_at: DateTime, } @@ -72,3 +81,49 @@ impl Related for Entity { } impl ActiveModelBehavior for ActiveModel {} + +/// How a group's allowed values of one rule type combine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum RuleMatch { + /// A series matches if it carries any of the allowed values + #[default] + Any, + /// A series matches only if it carries every allowed value + All, +} + +impl RuleMatch { + pub fn as_str(&self) -> &'static str { + match self { + RuleMatch::Any => "any", + RuleMatch::All => "all", + } + } +} + +impl std::str::FromStr for RuleMatch { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "any" => Ok(RuleMatch::Any), + "all" => Ok(RuleMatch::All), + _ => Err(format!("Unknown rule match: {}", s)), + } + } +} + +impl Model { + pub fn get_sharing_tag_match(&self) -> RuleMatch { + self.sharing_tag_match.parse().unwrap_or_default() + } + + pub fn get_tag_match(&self) -> RuleMatch { + self.tag_match.parse().unwrap_or_default() + } + + pub fn get_genre_match(&self) -> RuleMatch { + self.genre_match.parse().unwrap_or_default() + } +} diff --git a/crates/codex-db/src/entities/mod.rs b/crates/codex-db/src/entities/mod.rs index 6ea386c5c..2ade08666 100644 --- a/crates/codex-db/src/entities/mod.rs +++ b/crates/codex-db/src/entities/mod.rs @@ -83,8 +83,11 @@ pub mod sharing_tags; pub mod user_sharing_tags; // Access groups for sharing tag management +pub mod access_group_genre_rules; +pub mod access_group_library_rules; pub mod access_group_oidc_mappings; pub mod access_group_sharing_tags; +pub mod access_group_tag_rules; pub mod access_groups; pub mod user_access_groups; diff --git a/crates/codex-db/src/entities/prelude.rs b/crates/codex-db/src/entities/prelude.rs index 0b01a1d57..e72571a7b 100644 --- a/crates/codex-db/src/entities/prelude.rs +++ b/crates/codex-db/src/entities/prelude.rs @@ -72,10 +72,15 @@ pub use super::user_sharing_tags::Entity as UserSharingTags; // Access groups #[allow(unused_imports)] +pub use super::access_group_genre_rules::Entity as AccessGroupGenreRules; +#[allow(unused_imports)] +pub use super::access_group_library_rules::Entity as AccessGroupLibraryRules; pub use super::access_group_oidc_mappings::Entity as AccessGroupOidcMappings; #[allow(unused_imports)] pub use super::access_group_sharing_tags::Entity as AccessGroupSharingTags; #[allow(unused_imports)] +pub use super::access_group_tag_rules::Entity as AccessGroupTagRules; +#[allow(unused_imports)] pub use super::access_groups::Entity as AccessGroups; #[allow(unused_imports)] pub use super::user_access_groups::Entity as UserAccessGroups; diff --git a/crates/codex-db/src/entities/user_access_groups.rs b/crates/codex-db/src/entities/user_access_groups.rs index 6048ca019..d5a984efa 100644 --- a/crates/codex-db/src/entities/user_access_groups.rs +++ b/crates/codex-db/src/entities/user_access_groups.rs @@ -71,6 +71,8 @@ pub enum MembershipSource { Manual, /// Auto-assigned from an OIDC IdP group claim Oidc, + /// Added at account creation because the group was the default group + Default, } impl MembershipSource { @@ -78,6 +80,7 @@ impl MembershipSource { match self { MembershipSource::Manual => "manual", MembershipSource::Oidc => "oidc", + MembershipSource::Default => "default", } } } @@ -89,6 +92,7 @@ impl FromStr for MembershipSource { match s.to_lowercase().as_str() { "manual" => Ok(MembershipSource::Manual), "oidc" => Ok(MembershipSource::Oidc), + "default" => Ok(MembershipSource::Default), _ => Err(format!("Unknown membership source: {}", s)), } } diff --git a/crates/codex-db/src/lib.rs b/crates/codex-db/src/lib.rs index 8e5dc51eb..343aeacbf 100644 --- a/crates/codex-db/src/lib.rs +++ b/crates/codex-db/src/lib.rs @@ -1,3 +1,4 @@ +pub mod access_control_version; pub mod connection; pub mod entities; pub mod repositories; diff --git a/crates/codex-db/src/repositories/access_group.rs b/crates/codex-db/src/repositories/access_group.rs index 3e5b0aa3a..30422422d 100644 --- a/crates/codex-db/src/repositories/access_group.rs +++ b/crates/codex-db/src/repositories/access_group.rs @@ -1,19 +1,21 @@ //! Repository for access groups, group memberships, group grants, and OIDC mappings. //! -//! Access groups bundle a set of sharing-tag allow/deny rules that can be -//! assigned to many users. Per-user grants in `user_sharing_tags` act as -//! overrides on top of group rules (see `SharingTagRepository::get_effective_grants`). +//! Access groups bundle allow/deny rules that can be assigned to many users. +//! The rules themselves live in `access_group_rules`; how they combine into +//! what a user can see is `ContentFilter` in codex-services. use anyhow::Result; use chrono::Utc; use sea_orm::{ - ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, + ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter, + QueryOrder, Set, TransactionTrait, sea_query::Expr, }; +use std::collections::{HashMap, HashSet}; use uuid::Uuid; use crate::entities::{ - access_group_oidc_mappings, access_group_sharing_tags, access_groups, user_access_groups, - user_access_groups::MembershipSource, user_sharing_tags::AccessMode, users, + access_group_oidc_mappings, access_group_sharing_tags, access_groups, access_groups::RuleMatch, + user_access_groups, user_access_groups::MembershipSource, user_sharing_tags::AccessMode, users, }; pub struct AccessGroupRepository; @@ -32,6 +34,10 @@ impl AccessGroupRepository { id: Set(Uuid::new_v4()), name: Set(name.trim().to_string()), description: Set(description), + is_default: Set(false), + sharing_tag_match: Set(RuleMatch::Any.as_str().to_string()), + tag_match: Set(RuleMatch::Any.as_str().to_string()), + genre_match: Set(RuleMatch::Any.as_str().to_string()), created_at: Set(now), updated_at: Set(now), }; @@ -93,15 +99,21 @@ impl AccessGroupRepository { /// Delete an access group (cascades memberships, grants, OIDC mappings via FK). pub async fn delete(db: &DatabaseConnection, id: Uuid) -> Result { let res = access_groups::Entity::delete_by_id(id).exec(db).await?; + crate::access_control_version::bump(); Ok(res.rows_affected > 0) } // ==================== Memberships ==================== - /// Add a user to a group. Idempotent: if the membership already exists - /// returns the existing row. Updates the `source` if it differs. + /// Add a user to a group. Idempotent: if the membership already exists, + /// the existing row is returned unchanged. + /// + /// The source of an existing row is never rewritten. Whoever added the + /// user first owns the membership: the IdP can only remove `oidc` rows, so + /// turning one into `manual` would make it permanent, and turning a manual + /// one into `oidc` would let the IdP remove what an admin assigned. pub async fn add_member( - db: &DatabaseConnection, + db: &impl ConnectionTrait, group_id: Uuid, user_id: Uuid, source: MembershipSource, @@ -112,12 +124,7 @@ impl AccessGroupRepository { .one(db) .await?; if let Some(row) = existing { - if row.source == source.as_str() { - return Ok(row); - } - let mut active: user_access_groups::ActiveModel = row.into(); - active.source = Set(source.as_str().to_string()); - return Ok(active.update(db).await?); + return Ok(row); } let active = user_access_groups::ActiveModel { id: Set(Uuid::new_v4()), @@ -126,12 +133,16 @@ impl AccessGroupRepository { source: Set(source.as_str().to_string()), created_at: Set(Utc::now()), }; - Ok(active.insert(db).await?) + let row = active.insert(db).await?; + // Inside a transaction this runs before the commit; such callers bump + // again after committing. + crate::access_control_version::bump(); + Ok(row) } /// Remove a user from a group. Returns true if a row was deleted. pub async fn remove_member( - db: &DatabaseConnection, + db: &impl ConnectionTrait, group_id: Uuid, user_id: Uuid, ) -> Result { @@ -140,6 +151,7 @@ impl AccessGroupRepository { .filter(user_access_groups::Column::AccessGroupId.eq(group_id)) .exec(db) .await?; + crate::access_control_version::bump(); Ok(res.rows_affected > 0) } @@ -192,6 +204,139 @@ impl AccessGroupRepository { .await?) } + // ==================== Default group ==================== + + /// The group new users join at account creation, if one is set. + pub async fn get_default(db: &impl ConnectionTrait) -> Result> { + Ok(access_groups::Entity::find() + .filter(access_groups::Column::IsDefault.eq(true)) + .one(db) + .await?) + } + + /// Make a group the default, or stop it being the default. + /// + /// Setting a new default clears the previous one in the same transaction. + /// Neither direction changes any membership: the default only applies to + /// accounts created afterwards (see [`Self::add_all_users`] for the rest). + pub async fn set_default( + db: &DatabaseConnection, + group_id: Uuid, + is_default: bool, + ) -> Result { + let txn = db.begin().await?; + let group = access_groups::Entity::find_by_id(group_id) + .one(&txn) + .await? + .ok_or_else(|| anyhow::anyhow!("Access group not found"))?; + + if is_default { + access_groups::Entity::update_many() + .col_expr(access_groups::Column::IsDefault, Expr::value(false)) + .filter(access_groups::Column::IsDefault.eq(true)) + .filter(access_groups::Column::Id.ne(group_id)) + .exec(&txn) + .await?; + } + + let mut active: access_groups::ActiveModel = group.into(); + active.is_default = Set(is_default); + active.updated_at = Set(Utc::now()); + let updated = active.update(&txn).await?; + txn.commit().await?; + Ok(updated) + } + + /// Add a user to the default group, if there is one. + /// + /// Called inside the user-creation transaction so every way of creating + /// an account gets the same baseline. + pub async fn join_default_group(db: &impl ConnectionTrait, user_id: Uuid) -> Result<()> { + if let Some(group) = Self::get_default(db).await? { + Self::add_member(db, group.id, user_id, MembershipSource::Default).await?; + } + Ok(()) + } + + /// Add every user who is not yet a member, with source `default`. + /// Returns how many users were added. + pub async fn add_all_users(db: &DatabaseConnection, group_id: Uuid) -> Result { + let txn = db.begin().await?; + let existing: HashSet = user_access_groups::Entity::find() + .filter(user_access_groups::Column::AccessGroupId.eq(group_id)) + .all(&txn) + .await? + .into_iter() + .map(|m| m.user_id) + .collect(); + let user_ids: Vec = users::Entity::find() + .all(&txn) + .await? + .into_iter() + .map(|u| u.id) + .filter(|id| !existing.contains(id)) + .collect(); + + let now = Utc::now(); + for user_id in &user_ids { + user_access_groups::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(*user_id), + access_group_id: Set(group_id), + source: Set(MembershipSource::Default.as_str().to_string()), + created_at: Set(now), + } + .insert(&txn) + .await?; + } + txn.commit().await?; + crate::access_control_version::bump(); + Ok(user_ids.len() as u64) + } + + /// Group memberships for many users in two queries, keyed by user ID. + /// + /// Each user's groups are sorted by name and paired with the membership + /// row, which carries the source. Users with no memberships are absent. + pub async fn list_memberships_for_users( + db: &DatabaseConnection, + user_ids: &[Uuid], + ) -> Result>> { + if user_ids.is_empty() { + return Ok(HashMap::new()); + } + let memberships = user_access_groups::Entity::find() + .filter(user_access_groups::Column::UserId.is_in(user_ids.to_vec())) + .all(db) + .await?; + if memberships.is_empty() { + return Ok(HashMap::new()); + } + let group_ids: HashSet = memberships.iter().map(|m| m.access_group_id).collect(); + let groups: HashMap = access_groups::Entity::find() + .filter(access_groups::Column::Id.is_in(group_ids)) + .all(db) + .await? + .into_iter() + .map(|g| (g.id, g)) + .collect(); + + let mut by_user: HashMap> = + HashMap::new(); + for membership in memberships { + if let Some(group) = groups.get(&membership.access_group_id) { + by_user + .entry(membership.user_id) + .or_default() + .push((group.clone(), membership)); + } + } + for entries in by_user.values_mut() { + entries.sort_by(|a, b| a.0.name.cmp(&b.0.name)); + } + Ok(by_user) + } + // ==================== Grants ==================== /// Set a group's grant for a sharing tag (upsert). If a row exists with a @@ -210,7 +355,9 @@ impl AccessGroupRepository { if let Some(row) = existing { let mut active: access_group_sharing_tags::ActiveModel = row.into(); active.access_mode = Set(access_mode.as_str().to_string()); - return Ok(active.update(db).await?); + let row = active.update(db).await?; + crate::access_control_version::bump(); + return Ok(row); } let active = access_group_sharing_tags::ActiveModel { id: Set(Uuid::new_v4()), @@ -219,7 +366,9 @@ impl AccessGroupRepository { access_mode: Set(access_mode.as_str().to_string()), created_at: Set(Utc::now()), }; - Ok(active.insert(db).await?) + let row = active.insert(db).await?; + crate::access_control_version::bump(); + Ok(row) } /// Remove a group's grant for a sharing tag. Returns true if a row was deleted. @@ -233,6 +382,7 @@ impl AccessGroupRepository { .filter(access_group_sharing_tags::Column::SharingTagId.eq(tag_id)) .exec(db) .await?; + crate::access_control_version::bump(); Ok(res.rows_affected > 0) } @@ -325,7 +475,10 @@ impl AccessGroupRepository { user_id: Uuid, oidc_groups: &[String], ) -> Result<(usize, usize)> { - use std::collections::HashSet; + // One transaction, so a failure part-way never leaves the user with + // half the IdP's groups applied. + let txn = db.begin().await?; + let db = &txn; // 1. Desired: access groups mapped to any of the user's OIDC group names let desired_group_ids: HashSet = if oidc_groups.is_empty() { @@ -373,6 +526,8 @@ impl AccessGroupRepository { Self::remove_member(db, *group_id, user_id).await?; } + txn.commit().await?; + crate::access_control_version::bump(); Ok((to_add.len(), to_remove.len())) } } @@ -401,6 +556,178 @@ mod tests { UserRepository::create(db, &model).await.unwrap() } + // ---------- Default group ---------- + + #[tokio::test] + async fn set_default_keeps_a_single_default() { + let db = setup_test_db().await; + let a = AccessGroupRepository::create(&db, "A", None).await.unwrap(); + let b = AccessGroupRepository::create(&db, "B", None).await.unwrap(); + assert!(!a.is_default); + assert!( + AccessGroupRepository::get_default(&db) + .await + .unwrap() + .is_none() + ); + + AccessGroupRepository::set_default(&db, a.id, true) + .await + .unwrap(); + assert_eq!( + AccessGroupRepository::get_default(&db) + .await + .unwrap() + .unwrap() + .id, + a.id + ); + + // Moving the default clears it from the previous group + AccessGroupRepository::set_default(&db, b.id, true) + .await + .unwrap(); + assert_eq!( + AccessGroupRepository::get_default(&db) + .await + .unwrap() + .unwrap() + .id, + b.id + ); + let a = AccessGroupRepository::get_by_id(&db, a.id) + .await + .unwrap() + .unwrap(); + assert!(!a.is_default); + + AccessGroupRepository::set_default(&db, b.id, false) + .await + .unwrap(); + assert!( + AccessGroupRepository::get_default(&db) + .await + .unwrap() + .is_none() + ); + } + + /// The partial unique index is the backstop if two requests race. + #[tokio::test] + async fn database_rejects_a_second_default() { + use sea_orm::ActiveModelTrait; + + let db = setup_test_db().await; + let a = AccessGroupRepository::create(&db, "A", None).await.unwrap(); + let b = AccessGroupRepository::create(&db, "B", None).await.unwrap(); + AccessGroupRepository::set_default(&db, a.id, true) + .await + .unwrap(); + + let mut active: access_groups::ActiveModel = b.into(); + active.is_default = Set(true); + assert!(active.update(&db).await.is_err()); + } + + #[tokio::test] + async fn new_user_joins_the_default_group() { + let db = setup_test_db().await; + let kids = AccessGroupRepository::create(&db, "Kids", None) + .await + .unwrap(); + AccessGroupRepository::set_default(&db, kids.id, true) + .await + .unwrap(); + + let user = create_test_user(&db, "newbie").await; + + let members = AccessGroupRepository::list_members(&db, kids.id) + .await + .unwrap(); + assert_eq!(members.len(), 1); + assert_eq!(members[0].user_id, user.id); + assert_eq!(members[0].source, "default"); + } + + #[tokio::test] + async fn new_user_without_a_default_group_joins_nothing() { + let db = setup_test_db().await; + AccessGroupRepository::create(&db, "Kids", None) + .await + .unwrap(); + + let user = create_test_user(&db, "newbie").await; + + assert!( + AccessGroupRepository::list_for_user(&db, user.id) + .await + .unwrap() + .is_empty() + ); + } + + /// Joining at creation is a one-off: once removed, the user stays out, + /// and making a group default later does not pull existing users in. + #[tokio::test] + async fn default_membership_is_removable_and_not_retroactive() { + let db = setup_test_db().await; + let existing = create_test_user(&db, "existing").await; + let kids = AccessGroupRepository::create(&db, "Kids", None) + .await + .unwrap(); + AccessGroupRepository::set_default(&db, kids.id, true) + .await + .unwrap(); + assert!( + AccessGroupRepository::list_for_user(&db, existing.id) + .await + .unwrap() + .is_empty() + ); + + let newbie = create_test_user(&db, "newbie").await; + AccessGroupRepository::remove_member(&db, kids.id, newbie.id) + .await + .unwrap(); + assert!( + AccessGroupRepository::list_for_user(&db, newbie.id) + .await + .unwrap() + .is_empty() + ); + } + + #[tokio::test] + async fn add_all_users_keeps_existing_memberships_as_they_are() { + let db = setup_test_db().await; + let group = AccessGroupRepository::create(&db, "Kids", None) + .await + .unwrap(); + let via_oidc = create_test_user(&db, "via_oidc").await; + let outsider = create_test_user(&db, "outsider").await; + AccessGroupRepository::add_member(&db, group.id, via_oidc.id, MembershipSource::Oidc) + .await + .unwrap(); + + let added = AccessGroupRepository::add_all_users(&db, group.id) + .await + .unwrap(); + assert_eq!(added, 1); + + let members = AccessGroupRepository::list_members(&db, group.id) + .await + .unwrap(); + let source_of = |id: Uuid| { + members + .iter() + .find(|m| m.user_id == id) + .map(|m| m.source.clone()) + }; + // An OIDC membership must stay OIDC, or the IdP could no longer remove it + assert_eq!(source_of(via_oidc.id).as_deref(), Some("oidc")); + assert_eq!(source_of(outsider.id).as_deref(), Some("default")); + } + // ---------- Group CRUD ---------- #[tokio::test] @@ -513,13 +840,82 @@ mod tests { 1 ); - // Different source: same row, source updated + // Different source: same row, original source kept let m3 = AccessGroupRepository::add_member(&db, group.id, user.id, MembershipSource::Oidc) .await .unwrap(); assert_eq!(m1.id, m3.id); - assert_eq!(m3.source, "oidc"); - assert_eq!(m3.get_source(), MembershipSource::Oidc); + assert_eq!(m3.get_source(), MembershipSource::Manual); + } + + /// An admin adding someone the IdP already put in the group must not take + /// the membership away from the IdP, or leaving the IdP group would no + /// longer remove them. + #[tokio::test] + async fn reconcile_still_removes_oidc_member_after_manual_add() { + let db = setup_test_db().await; + let user = create_test_user(&db, "alice").await; + let group = AccessGroupRepository::create(&db, "Team", None) + .await + .unwrap(); + AccessGroupRepository::add_oidc_mapping(&db, group.id, "idp-team") + .await + .unwrap(); + AccessGroupRepository::reconcile_oidc_group_memberships( + &db, + user.id, + &["idp-team".to_string()], + ) + .await + .unwrap(); + + AccessGroupRepository::add_member(&db, group.id, user.id, MembershipSource::Manual) + .await + .unwrap(); + AccessGroupRepository::reconcile_oidc_group_memberships(&db, user.id, &[]) + .await + .unwrap(); + + assert!( + AccessGroupRepository::list_for_user(&db, user.id) + .await + .unwrap() + .is_empty() + ); + } + + /// The reverse: a manual membership in a group the IdP also maps must + /// survive the user leaving the IdP group. + #[tokio::test] + async fn reconcile_keeps_manual_member_when_idp_group_also_maps() { + let db = setup_test_db().await; + let user = create_test_user(&db, "alice").await; + let group = AccessGroupRepository::create(&db, "Team", None) + .await + .unwrap(); + AccessGroupRepository::add_member(&db, group.id, user.id, MembershipSource::Manual) + .await + .unwrap(); + AccessGroupRepository::add_oidc_mapping(&db, group.id, "idp-team") + .await + .unwrap(); + + AccessGroupRepository::reconcile_oidc_group_memberships( + &db, + user.id, + &["idp-team".to_string()], + ) + .await + .unwrap(); + AccessGroupRepository::reconcile_oidc_group_memberships(&db, user.id, &[]) + .await + .unwrap(); + + let members = AccessGroupRepository::list_members(&db, group.id) + .await + .unwrap(); + assert_eq!(members.len(), 1); + assert_eq!(members[0].source, "manual"); } #[tokio::test] diff --git a/crates/codex-db/src/repositories/access_group_rules.rs b/crates/codex-db/src/repositories/access_group_rules.rs new file mode 100644 index 000000000..750b3ee65 --- /dev/null +++ b/crates/codex-db/src/repositories/access_group_rules.rs @@ -0,0 +1,635 @@ +//! Access group rules: allow/deny on sharing tags, regular tags, genres and +//! libraries, plus the per-type "any"/"all" switches. +//! +//! This module only stores rules and resolves rule targets to series IDs. How +//! rules from several groups combine into what a user can see lives in +//! `ContentFilter` (`codex-services`). +//! +//! Tag and genre rules are keyed by normalized name (see the migration that +//! creates their tables for why), so resolving them joins on +//! `tags.normalized_name` / `genres.normalized_name` at query time. + +use anyhow::{Result, bail}; +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter, + QueryOrder, QuerySelect, Set, TransactionTrait, +}; +use std::collections::{HashMap, HashSet}; +use uuid::Uuid; + +use crate::entities::{ + access_group_genre_rules, access_group_library_rules, access_group_sharing_tags, + access_group_tag_rules, access_groups, access_groups::RuleMatch, genres, series, series_genres, + series_sharing_tags, series_tags, tags, user_sharing_tags::AccessMode, +}; + +/// Normalize a tag or genre name the way the `tags` and `genres` tables do. +pub fn normalize_taxonomy_name(name: &str) -> String { + name.trim().to_lowercase() +} + +/// A rule on a sharing tag. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SharingTagRule { + pub sharing_tag_id: Uuid, + pub access_mode: AccessMode, +} + +/// A rule on a regular tag or a genre, by name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NameRule { + /// Display name; matching uses [`normalize_taxonomy_name`] of it + pub name: String, + pub access_mode: AccessMode, +} + +impl NameRule { + pub fn new(name: impl Into, access_mode: AccessMode) -> Self { + Self { + name: name.into(), + access_mode, + } + } + + pub fn normalized_name(&self) -> String { + normalize_taxonomy_name(&self.name) + } +} + +/// A rule on a library. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LibraryRule { + pub library_id: Uuid, + pub access_mode: AccessMode, +} + +/// Every rule an access group holds. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AccessGroupRules { + pub sharing_tag_match: RuleMatch, + pub tag_match: RuleMatch, + pub genre_match: RuleMatch, + pub sharing_tags: Vec, + pub tags: Vec, + pub genres: Vec, + pub libraries: Vec, +} + +impl AccessGroupRules { + /// Whether the group has any allow rule, of any type. + pub fn has_allows(&self) -> bool { + self.sharing_tags + .iter() + .any(|r| r.access_mode == AccessMode::Allow) + || self.tags.iter().any(|r| r.access_mode == AccessMode::Allow) + || self + .genres + .iter() + .any(|r| r.access_mode == AccessMode::Allow) + || self + .libraries + .iter() + .any(|r| r.access_mode == AccessMode::Allow) + } + + /// Check the rule set before storing it. + /// + /// Each target may appear once per group (one row per target in the + /// tables), and names must not be blank. + pub fn validate(&self) -> std::result::Result<(), String> { + let mut seen = HashSet::new(); + for rule in &self.sharing_tags { + if !seen.insert(rule.sharing_tag_id) { + return Err(format!( + "Sharing tag {} appears more than once", + rule.sharing_tag_id + )); + } + } + for (kind, rules) in [("tag", &self.tags), ("genre", &self.genres)] { + let mut seen = HashSet::new(); + for rule in rules { + let normalized = rule.normalized_name(); + if normalized.is_empty() { + return Err(format!("A {kind} rule has an empty name")); + } + if !seen.insert(normalized) { + return Err(format!( + "The {kind} '{}' appears more than once", + rule.name.trim() + )); + } + } + } + let mut seen = HashSet::new(); + for rule in &self.libraries { + if !seen.insert(rule.library_id) { + return Err(format!( + "Library {} appears more than once", + rule.library_id + )); + } + } + Ok(()) + } +} + +pub struct AccessGroupRulesRepository; + +impl AccessGroupRulesRepository { + /// All rules of one group. Returns `None` if the group does not exist. + pub async fn get(db: &DatabaseConnection, group_id: Uuid) -> Result> { + Ok(Self::load_for_groups(db, &[group_id]) + .await? + .remove(&group_id)) + } + + /// Rules for many groups in a fixed number of queries, keyed by group ID. + /// Groups that do not exist are absent from the map. + pub async fn load_for_groups( + db: &impl ConnectionTrait, + group_ids: &[Uuid], + ) -> Result> { + if group_ids.is_empty() { + return Ok(HashMap::new()); + } + let ids = group_ids.to_vec(); + + let mut by_group: HashMap = access_groups::Entity::find() + .filter(access_groups::Column::Id.is_in(ids.clone())) + .all(db) + .await? + .into_iter() + .map(|g| { + ( + g.id, + AccessGroupRules { + sharing_tag_match: g.get_sharing_tag_match(), + tag_match: g.get_tag_match(), + genre_match: g.get_genre_match(), + ..Default::default() + }, + ) + }) + .collect(); + + for row in access_group_sharing_tags::Entity::find() + .filter(access_group_sharing_tags::Column::AccessGroupId.is_in(ids.clone())) + .order_by_asc(access_group_sharing_tags::Column::CreatedAt) + .all(db) + .await? + { + if let Some(rules) = by_group.get_mut(&row.access_group_id) { + rules.sharing_tags.push(SharingTagRule { + sharing_tag_id: row.sharing_tag_id, + access_mode: row.get_access_mode(), + }); + } + } + + for row in access_group_tag_rules::Entity::find() + .filter(access_group_tag_rules::Column::AccessGroupId.is_in(ids.clone())) + .order_by_asc(access_group_tag_rules::Column::Name) + .all(db) + .await? + { + if let Some(rules) = by_group.get_mut(&row.access_group_id) { + let access_mode = row.get_access_mode(); + rules.tags.push(NameRule::new(row.name, access_mode)); + } + } + + for row in access_group_genre_rules::Entity::find() + .filter(access_group_genre_rules::Column::AccessGroupId.is_in(ids.clone())) + .order_by_asc(access_group_genre_rules::Column::Name) + .all(db) + .await? + { + if let Some(rules) = by_group.get_mut(&row.access_group_id) { + let access_mode = row.get_access_mode(); + rules.genres.push(NameRule::new(row.name, access_mode)); + } + } + + for row in access_group_library_rules::Entity::find() + .filter(access_group_library_rules::Column::AccessGroupId.is_in(ids)) + .order_by_asc(access_group_library_rules::Column::CreatedAt) + .all(db) + .await? + { + if let Some(rules) = by_group.get_mut(&row.access_group_id) { + rules.libraries.push(LibraryRule { + library_id: row.library_id, + access_mode: row.get_access_mode(), + }); + } + } + + Ok(by_group) + } + + /// Replace every rule of a group, atomically. + /// + /// Callers should [`AccessGroupRules::validate`] first; a duplicate target + /// is also refused by the tables' unique indexes. + pub async fn replace( + db: &DatabaseConnection, + group_id: Uuid, + rules: &AccessGroupRules, + ) -> Result<()> { + if let Err(message) = rules.validate() { + bail!(message); + } + + let txn = db.begin().await?; + let group = access_groups::Entity::find_by_id(group_id) + .one(&txn) + .await? + .ok_or_else(|| anyhow::anyhow!("Access group not found"))?; + + access_group_sharing_tags::Entity::delete_many() + .filter(access_group_sharing_tags::Column::AccessGroupId.eq(group_id)) + .exec(&txn) + .await?; + access_group_tag_rules::Entity::delete_many() + .filter(access_group_tag_rules::Column::AccessGroupId.eq(group_id)) + .exec(&txn) + .await?; + access_group_genre_rules::Entity::delete_many() + .filter(access_group_genre_rules::Column::AccessGroupId.eq(group_id)) + .exec(&txn) + .await?; + access_group_library_rules::Entity::delete_many() + .filter(access_group_library_rules::Column::AccessGroupId.eq(group_id)) + .exec(&txn) + .await?; + + let now = Utc::now(); + for rule in &rules.sharing_tags { + access_group_sharing_tags::ActiveModel { + id: Set(Uuid::new_v4()), + access_group_id: Set(group_id), + sharing_tag_id: Set(rule.sharing_tag_id), + access_mode: Set(rule.access_mode.as_str().to_string()), + created_at: Set(now), + } + .insert(&txn) + .await?; + } + for rule in &rules.tags { + access_group_tag_rules::ActiveModel { + id: Set(Uuid::new_v4()), + access_group_id: Set(group_id), + name: Set(rule.name.trim().to_string()), + normalized_name: Set(rule.normalized_name()), + access_mode: Set(rule.access_mode.as_str().to_string()), + created_at: Set(now), + } + .insert(&txn) + .await?; + } + for rule in &rules.genres { + access_group_genre_rules::ActiveModel { + id: Set(Uuid::new_v4()), + access_group_id: Set(group_id), + name: Set(rule.name.trim().to_string()), + normalized_name: Set(rule.normalized_name()), + access_mode: Set(rule.access_mode.as_str().to_string()), + created_at: Set(now), + } + .insert(&txn) + .await?; + } + for rule in &rules.libraries { + access_group_library_rules::ActiveModel { + id: Set(Uuid::new_v4()), + access_group_id: Set(group_id), + library_id: Set(rule.library_id), + access_mode: Set(rule.access_mode.as_str().to_string()), + created_at: Set(now), + } + .insert(&txn) + .await?; + } + + let mut active: access_groups::ActiveModel = group.into(); + active.sharing_tag_match = Set(rules.sharing_tag_match.as_str().to_string()); + active.tag_match = Set(rules.tag_match.as_str().to_string()); + active.genre_match = Set(rules.genre_match.as_str().to_string()); + active.updated_at = Set(now); + active.update(&txn).await?; + + txn.commit().await?; + crate::access_control_version::bump(); + Ok(()) + } + + // ==================== Resolving targets to series ==================== + + /// Series carrying each of the given sharing tags, keyed by tag ID. + pub async fn series_by_sharing_tag( + db: &impl ConnectionTrait, + sharing_tag_ids: &[Uuid], + ) -> Result>> { + let mut out: HashMap> = HashMap::new(); + if sharing_tag_ids.is_empty() { + return Ok(out); + } + for row in series_sharing_tags::Entity::find() + .filter(series_sharing_tags::Column::SharingTagId.is_in(sharing_tag_ids.to_vec())) + .all(db) + .await? + { + out.entry(row.sharing_tag_id) + .or_default() + .insert(row.series_id); + } + Ok(out) + } + + /// Series carrying each of the given tags, keyed by normalized tag name. + pub async fn series_by_tag_name( + db: &impl ConnectionTrait, + normalized_names: &[String], + ) -> Result>> { + let mut out: HashMap> = HashMap::new(); + if normalized_names.is_empty() { + return Ok(out); + } + let rows: Vec<(Uuid, String)> = series_tags::Entity::find() + .select_only() + .column(series_tags::Column::SeriesId) + .column(tags::Column::NormalizedName) + .inner_join(tags::Entity) + .filter(tags::Column::NormalizedName.is_in(normalized_names.to_vec())) + .into_tuple() + .all(db) + .await?; + for (series_id, name) in rows { + out.entry(name).or_default().insert(series_id); + } + Ok(out) + } + + /// Series carrying each of the given genres, keyed by normalized genre name. + pub async fn series_by_genre_name( + db: &impl ConnectionTrait, + normalized_names: &[String], + ) -> Result>> { + let mut out: HashMap> = HashMap::new(); + if normalized_names.is_empty() { + return Ok(out); + } + let rows: Vec<(Uuid, String)> = series_genres::Entity::find() + .select_only() + .column(series_genres::Column::SeriesId) + .column(genres::Column::NormalizedName) + .inner_join(genres::Entity) + .filter(genres::Column::NormalizedName.is_in(normalized_names.to_vec())) + .into_tuple() + .all(db) + .await?; + for (series_id, name) in rows { + out.entry(name).or_default().insert(series_id); + } + Ok(out) + } + + /// Series in each of the given libraries, keyed by library ID. + pub async fn series_by_library( + db: &impl ConnectionTrait, + library_ids: &[Uuid], + ) -> Result>> { + let mut out: HashMap> = HashMap::new(); + if library_ids.is_empty() { + return Ok(out); + } + let rows: Vec<(Uuid, Uuid)> = series::Entity::find() + .select_only() + .column(series::Column::Id) + .column(series::Column::LibraryId) + .filter(series::Column::LibraryId.is_in(library_ids.to_vec())) + .into_tuple() + .all(db) + .await?; + for (series_id, library_id) in rows { + out.entry(library_id).or_default().insert(series_id); + } + Ok(out) + } +} + +impl AccessGroupRulesRepository { + /// The libraries that hold the given series. Queried in chunks so a large + /// whitelist stays under the database's bound-parameter limit. + pub async fn library_ids_of_series( + db: &impl ConnectionTrait, + series_ids: &[Uuid], + ) -> Result> { + let mut out = HashSet::new(); + for chunk in series_ids.chunks(500) { + let rows: Vec = series::Entity::find() + .select_only() + .column(series::Column::LibraryId) + .distinct() + .filter(series::Column::Id.is_in(chunk.to_vec())) + .into_tuple() + .all(db) + .await?; + out.extend(rows); + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ScanningStrategy; + use crate::repositories::{AccessGroupRepository, GenreRepository, LibraryRepository}; + use crate::test_helpers::setup_test_db; + + #[test] + fn validate_rejects_duplicate_names_after_normalizing() { + let rules = AccessGroupRules { + genres: vec![ + NameRule::new("Adult", AccessMode::Deny), + NameRule::new(" adult ", AccessMode::Allow), + ], + ..Default::default() + }; + assert!(rules.validate().is_err()); + } + + #[test] + fn validate_rejects_blank_names() { + let rules = AccessGroupRules { + tags: vec![NameRule::new(" ", AccessMode::Deny)], + ..Default::default() + }; + assert!(rules.validate().is_err()); + } + + #[test] + fn has_allows_ignores_denies() { + let deny_only = AccessGroupRules { + genres: vec![NameRule::new("Adult", AccessMode::Deny)], + ..Default::default() + }; + assert!(!deny_only.has_allows()); + + let with_allow = AccessGroupRules { + libraries: vec![LibraryRule { + library_id: Uuid::new_v4(), + access_mode: AccessMode::Allow, + }], + ..deny_only + }; + assert!(with_allow.has_allows()); + } + + #[tokio::test] + async fn replace_and_get_round_trip() { + let db = setup_test_db().await; + let group = AccessGroupRepository::create(&db, "Kids", None) + .await + .unwrap(); + let library = + LibraryRepository::create(&db, "Kids", "/tmp/kids", ScanningStrategy::Default) + .await + .unwrap(); + + let rules = AccessGroupRules { + genre_match: RuleMatch::All, + tags: vec![NameRule::new("Gore", AccessMode::Deny)], + genres: vec![ + NameRule::new("Adult", AccessMode::Deny), + NameRule::new("Kids", AccessMode::Allow), + ], + libraries: vec![LibraryRule { + library_id: library.id, + access_mode: AccessMode::Allow, + }], + ..Default::default() + }; + AccessGroupRulesRepository::replace(&db, group.id, &rules) + .await + .unwrap(); + + let stored = AccessGroupRulesRepository::get(&db, group.id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored, rules); + + // Replacing again drops what is not in the new set + AccessGroupRulesRepository::replace(&db, group.id, &AccessGroupRules::default()) + .await + .unwrap(); + let stored = AccessGroupRulesRepository::get(&db, group.id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored, AccessGroupRules::default()); + } + + #[tokio::test] + async fn replace_refuses_an_invalid_rule_set_and_keeps_the_old_one() { + let db = setup_test_db().await; + let group = AccessGroupRepository::create(&db, "Kids", None) + .await + .unwrap(); + let original = AccessGroupRules { + genres: vec![NameRule::new("Adult", AccessMode::Deny)], + ..Default::default() + }; + AccessGroupRulesRepository::replace(&db, group.id, &original) + .await + .unwrap(); + + let invalid = AccessGroupRules { + genres: vec![ + NameRule::new("Kids", AccessMode::Allow), + NameRule::new("KIDS", AccessMode::Deny), + ], + ..Default::default() + }; + assert!( + AccessGroupRulesRepository::replace(&db, group.id, &invalid) + .await + .is_err() + ); + + let stored = AccessGroupRulesRepository::get(&db, group.id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored, original); + } + + /// The reason genre rules are keyed by name: cleanup deletes unused + /// genres, and the rule must still apply when the genre comes back. + #[tokio::test] + async fn genre_rule_survives_genre_cleanup() { + let db = setup_test_db().await; + let group = AccessGroupRepository::create(&db, "Kids", None) + .await + .unwrap(); + GenreRepository::create(&db, "Adult").await.unwrap(); + AccessGroupRulesRepository::replace( + &db, + group.id, + &AccessGroupRules { + genres: vec![NameRule::new("Adult", AccessMode::Deny)], + ..Default::default() + }, + ) + .await + .unwrap(); + + GenreRepository::delete_unused(&db).await.unwrap(); + + let stored = AccessGroupRulesRepository::get(&db, group.id) + .await + .unwrap() + .unwrap(); + assert_eq!( + stored.genres, + vec![NameRule::new("Adult", AccessMode::Deny)] + ); + } + + #[tokio::test] + async fn deleting_a_library_removes_its_rules() { + let db = setup_test_db().await; + let group = AccessGroupRepository::create(&db, "Kids", None) + .await + .unwrap(); + let library = + LibraryRepository::create(&db, "Adult", "/tmp/adult", ScanningStrategy::Default) + .await + .unwrap(); + AccessGroupRulesRepository::replace( + &db, + group.id, + &AccessGroupRules { + libraries: vec![LibraryRule { + library_id: library.id, + access_mode: AccessMode::Deny, + }], + ..Default::default() + }, + ) + .await + .unwrap(); + + LibraryRepository::delete(&db, library.id).await.unwrap(); + + let stored = AccessGroupRulesRepository::get(&db, group.id) + .await + .unwrap() + .unwrap(); + assert!(stored.libraries.is_empty()); + } +} diff --git a/crates/codex-db/src/repositories/book.rs b/crates/codex-db/src/repositories/book.rs index 02cda87f8..d74e63209 100644 --- a/crates/codex-db/src/repositories/book.rs +++ b/crates/codex-db/src/repositories/book.rs @@ -41,6 +41,8 @@ pub struct BookQueryOptions<'a> { pub page: u64, /// Page size pub page_size: u64, + /// Only books in series this lets through (`None` for no visibility constraint) + pub visibility: Option<&'a crate::repositories::SeriesVisibility>, } /// Release date filter for querying books by their metadata release date @@ -163,6 +165,7 @@ impl BookRepository { if let Some(series_id) = options.series_id { query = query.filter(books::Column::SeriesId.eq(series_id)); } + query = crate::repositories::visibility::apply_book_visibility(query, options.visibility); if !options.include_deleted { query = query.filter(books::Column::Deleted.eq(false)); @@ -2025,6 +2028,25 @@ impl BookRepository { Ok(()) } + /// Count the non-deleted books in a library that pass a visibility filter + /// (`None` counts every book). + pub async fn count_visible_by_library( + db: &DatabaseConnection, + library_id: Uuid, + visibility: Option<&crate::repositories::SeriesVisibility>, + ) -> Result { + use sea_orm::PaginatorTrait; + + let query = Books::find() + .filter(books::Column::LibraryId.eq(library_id)) + .filter(books::Column::Deleted.eq(false)); + let count = crate::repositories::visibility::apply_book_visibility(query, visibility) + .paginate(db, 1) + .num_items() + .await?; + Ok(count as i64) + } + /// Count books in a library (excluding deleted books) pub async fn count_by_library(db: &DatabaseConnection, library_id: Uuid) -> Result { // Get all series in the library @@ -2524,6 +2546,7 @@ impl BookRepository { series_id: Option, error_type: Option, window: Window, + visibility: Option<&crate::repositories::SeriesVisibility>, ) -> Result<(Vec<(books::Model, BookErrors)>, u64)> { let (offset, limit) = (window.offset(), window.limit()); use crate::entities::book_error::parse_analysis_errors; @@ -2539,6 +2562,7 @@ impl BookRepository { if let Some(ser_id) = series_id { query = query.filter(books::Column::SeriesId.eq(ser_id)); } + query = crate::repositories::visibility::apply_book_visibility(query, visibility); // First, get all matching books let all_books: Vec = query @@ -2585,6 +2609,7 @@ impl BookRepository { pub async fn count_errors_by_type( db: &DatabaseConnection, library_id: Option, + visibility: Option<&crate::repositories::SeriesVisibility>, ) -> Result> { use crate::entities::book_error::parse_analysis_errors; @@ -2595,6 +2620,7 @@ impl BookRepository { if let Some(lib_id) = library_id { query = query.filter(books::Column::LibraryId.eq(lib_id)); } + query = crate::repositories::visibility::apply_book_visibility(query, visibility); let books = query .all(db) @@ -4249,6 +4275,7 @@ mod tests { None, None, Window::from_offset(0, 10), + None, ) .await .unwrap(); @@ -4263,6 +4290,7 @@ mod tests { None, Some(BookErrorType::Parser), Window::from_offset(0, 10), + None, ) .await .unwrap(); @@ -4277,6 +4305,7 @@ mod tests { None, Some(BookErrorType::Thumbnail), Window::from_offset(0, 10), + None, ) .await .unwrap(); @@ -4292,6 +4321,7 @@ mod tests { None, None, Window::from_offset(0, 1), + None, ) .await .unwrap(); @@ -4311,6 +4341,7 @@ mod tests { None, None, Window::from_offset(1, 2), + None, ) .await .unwrap(); @@ -4397,7 +4428,7 @@ mod tests { .unwrap(); // Count errors by type - let counts = BookRepository::count_errors_by_type(db.sea_orm_connection(), None) + let counts = BookRepository::count_errors_by_type(db.sea_orm_connection(), None, None) .await .unwrap(); @@ -4841,6 +4872,7 @@ mod tests { None, None, Window::from_offset(0, 6), + None, ) .await .unwrap(); @@ -4857,6 +4889,7 @@ mod tests { None, None, Window::from_offset(offset, 3), + None, ) .await .unwrap(); diff --git a/crates/codex-db/src/repositories/genre.rs b/crates/codex-db/src/repositories/genre.rs index d769222b7..80353caa3 100644 --- a/crates/codex-db/src/repositories/genre.rs +++ b/crates/codex-db/src/repositories/genre.rs @@ -4,6 +4,7 @@ #![allow(dead_code)] +use super::access_group_rules::normalize_taxonomy_name; use anyhow::Result; use chrono::Utc; use sea_orm::{ @@ -28,7 +29,7 @@ impl GenreRepository { /// Get a genre by normalized name pub async fn get_by_name(db: &DatabaseConnection, name: &str) -> Result> { - let normalized = name.to_lowercase().trim().to_string(); + let normalized = normalize_taxonomy_name(name); let result = Genres::find() .filter(genres::Column::NormalizedName.eq(&normalized)) .one(db) @@ -47,7 +48,7 @@ impl GenreRepository { /// Create a new genre pub async fn create(db: &DatabaseConnection, name: &str) -> Result { - let normalized = name.to_lowercase().trim().to_string(); + let normalized = normalize_taxonomy_name(name); let trimmed_name = name.trim().to_string(); let active_model = genres::ActiveModel { @@ -187,6 +188,37 @@ impl GenreRepository { Ok(result.rows_affected > 0) } + /// Series count per genre, counting only series `visibility` lets through + /// (`None` counts every series). Genres with no counted series are absent. + pub async fn series_counts( + db: &DatabaseConnection, + visibility: Option<&crate::repositories::SeriesVisibility>, + ) -> Result> { + use sea_orm::QuerySelect; + + let mut query = series_genres::Entity::find() + .select_only() + .column(series_genres::Column::GenreId) + .column_as(series_genres::Column::SeriesId.count(), "count") + .group_by(series_genres::Column::GenreId); + if let Some(vis) = visibility { + if vis.is_empty_whitelist() { + return Ok(std::collections::HashMap::new()); + } + if let Some(predicate) = crate::repositories::visibility::visibility_predicate( + series_genres::Column::SeriesId, + vis, + ) { + query = query.filter(predicate); + } + } + let rows: Vec<(Uuid, i64)> = query.into_tuple().all(db).await?; + Ok(rows + .into_iter() + .map(|(id, count)| (id, count.max(0) as u64)) + .collect()) + } + /// Count series using a genre pub async fn count_series_with_genre(db: &DatabaseConnection, genre_id: Uuid) -> Result { use crate::entities::series_genres::Entity as SeriesGenres; @@ -206,7 +238,7 @@ impl GenreRepository { ) -> Result> { use crate::entities::series_genres::Entity as SeriesGenres; - let normalized = genre_name.to_lowercase().trim().to_string(); + let normalized = normalize_taxonomy_name(genre_name); // First find the genre let genre = Genres::find() diff --git a/crates/codex-db/src/repositories/mod.rs b/crates/codex-db/src/repositories/mod.rs index b76bf7570..013b26044 100644 --- a/crates/codex-db/src/repositories/mod.rs +++ b/crates/codex-db/src/repositories/mod.rs @@ -53,6 +53,7 @@ pub mod visibility; // Access groups: reusable bundles of sharing-tag grants assignable to users pub mod access_group; +pub mod access_group_rules; // OIDC authentication pub mod oidc_connection; @@ -143,6 +144,10 @@ pub use visibility::{ // Access groups pub use access_group::AccessGroupRepository; +pub use access_group_rules::{ + AccessGroupRules, AccessGroupRulesRepository, LibraryRule, NameRule, SharingTagRule, + normalize_taxonomy_name, +}; // OIDC authentication pub use oidc_connection::OidcConnectionRepository; diff --git a/crates/codex-db/src/repositories/release_ledger.rs b/crates/codex-db/src/repositories/release_ledger.rs index f248159c3..4e85e6ac3 100644 --- a/crates/codex-db/src/repositories/release_ledger.rs +++ b/crates/codex-db/src/repositories/release_ledger.rs @@ -81,6 +81,8 @@ pub struct LedgerInboxFilter { pub language: Option, /// Restrict to series belonging to this library. pub library_id: Option, + /// Only rows for series this lets through (`None` for no constraint). + pub visibility: Option, } /// Sortable column for the cross-series inbox view. @@ -625,6 +627,14 @@ where if let Some(ref lang) = filter.language { query = query.filter(release_ledger::Column::Language.eq(lang)); } + if let Some(vis) = &filter.visibility + && let Some(predicate) = crate::repositories::visibility::visibility_predicate( + release_ledger::Column::SeriesId, + vis, + ) + { + query = query.filter(predicate); + } if let Some(lib_id) = filter.library_id { if !series_already_joined { query = query.join(JoinType::InnerJoin, release_ledger::Relation::Series.def()); @@ -692,6 +702,61 @@ mod tests { } } + /// The inbox, its count and its facets only cover series the visibility + /// filter lets through. + #[tokio::test] + async fn inbox_respects_visibility() { + use crate::repositories::SeriesVisibility; + use codex_models::pagination::Window; + + let (db, _temp) = create_test_db().await; + let conn = db.sea_orm_connection(); + let (visible_id, source_id) = setup_world(conn).await; + let library_id = SeriesRepository::get_by_id(conn, visible_id) + .await + .unwrap() + .unwrap() + .library_id; + let hidden_id = SeriesRepository::create(conn, library_id, "Hidden", None) + .await + .unwrap() + .id; + ReleaseLedgerRepository::record(conn, entry(visible_id, source_id, "rel-v")) + .await + .unwrap(); + ReleaseLedgerRepository::record(conn, entry(hidden_id, source_id, "rel-h")) + .await + .unwrap(); + + let filter = LedgerInboxFilter { + visibility: Some(SeriesVisibility { + excluded_series_ids: vec![hidden_id], + allowed_series_ids: None, + }), + ..Default::default() + }; + let rows = ReleaseLedgerRepository::list_inbox_sorted( + conn, + filter.clone(), + InboxSort::default(), + Window::unbounded(), + ) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].series_id, visible_id); + assert_eq!( + ReleaseLedgerRepository::count_inbox(conn, filter.clone()) + .await + .unwrap(), + 1 + ); + let facets = ReleaseLedgerRepository::list_series_facets(conn, filter) + .await + .unwrap(); + assert_eq!(facets.len(), 1); + } + #[tokio::test] async fn record_uses_initial_state_when_provided() { let (db, _temp) = create_test_db().await; diff --git a/crates/codex-db/src/repositories/series.rs b/crates/codex-db/src/repositories/series.rs index 45bcabe5a..a692c0ee2 100644 --- a/crates/codex-db/src/repositories/series.rs +++ b/crates/codex-db/src/repositories/series.rs @@ -40,6 +40,8 @@ pub struct SeriesQueryOptions<'a> { pub page: u64, /// Page size pub page_size: u64, + /// Only series this lets through (`None` for no visibility constraint) + pub visibility: Option<&'a SeriesVisibility>, } /// Sort configuration for series queries @@ -275,6 +277,7 @@ impl SeriesRepository { if let Some(library_id) = options.library_id { query = query.filter(series::Column::LibraryId.eq(library_id)); } + query = apply_series_visibility(query, options.visibility); // Handle text search if let Some(search) = options.search @@ -372,6 +375,7 @@ impl SeriesRepository { if let Some(library_id) = options.library_id { query = query.filter(series::Column::LibraryId.eq(library_id)); } + query = apply_series_visibility(query, options.visibility); // Handle text search if let Some(search) = options.search @@ -394,6 +398,7 @@ impl SeriesRepository { } else { count_query }; + count_query = apply_series_visibility(count_query, options.visibility); if let Some(search) = options.search && !search.is_empty() @@ -463,6 +468,7 @@ impl SeriesRepository { if let Some(library_id) = options.library_id { query = query.filter(series::Column::LibraryId.eq(library_id)); } + query = apply_series_visibility(query, options.visibility); // Handle text search if let Some(search) = options.search @@ -485,6 +491,7 @@ impl SeriesRepository { } else { count_query }; + count_query = apply_series_visibility(count_query, options.visibility); if let Some(search) = options.search && !search.is_empty() @@ -587,6 +594,7 @@ impl SeriesRepository { if let Some(library_id) = options.library_id { base_query = base_query.filter(series::Column::LibraryId.eq(library_id)); } + base_query = apply_series_visibility(base_query, options.visibility); if let Some(search) = options.search && !search.is_empty() @@ -605,6 +613,7 @@ impl SeriesRepository { if let Some(library_id) = options.library_id { count_query = count_query.filter(series::Column::LibraryId.eq(library_id)); } + count_query = apply_series_visibility(count_query, options.visibility); if let Some(search) = options.search && !search.is_empty() { @@ -1037,6 +1046,38 @@ impl SeriesRepository { Ok(count as i64) } + /// Count all series that pass a visibility filter (`None` counts every series). + pub async fn count_visible( + db: &DatabaseConnection, + visibility: Option<&SeriesVisibility>, + ) -> Result { + use sea_orm::PaginatorTrait; + + apply_series_visibility(Series::find(), visibility) + .paginate(db, 1) + .num_items() + .await + .context("Failed to count series") + } + + /// Count the series in a library that pass a visibility filter + /// (`None` counts every series). + pub async fn count_visible_by_library( + db: &DatabaseConnection, + library_id: Uuid, + visibility: Option<&SeriesVisibility>, + ) -> Result { + use sea_orm::PaginatorTrait; + + let query = Series::find().filter(series::Column::LibraryId.eq(library_id)); + let count = apply_series_visibility(query, visibility) + .paginate(db, 1) + .num_items() + .await + .context("Failed to count series")?; + Ok(count as i64) + } + /// Get all series across all libraries pub async fn list_all( db: &DatabaseConnection, diff --git a/crates/codex-db/src/repositories/sharing_tag.rs b/crates/codex-db/src/repositories/sharing_tag.rs index 394a05965..053e0381d 100644 --- a/crates/codex-db/src/repositories/sharing_tag.rs +++ b/crates/codex-db/src/repositories/sharing_tag.rs @@ -3,13 +3,12 @@ //! Sharing tags control content access. Series can be tagged, and users can be granted //! access (allow/deny) to content via these tags. -use anyhow::{Context, Result}; +use anyhow::Result; use chrono::Utc; use sea_orm::{ - ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, DbBackend, EntityTrait, - PaginatorTrait, QueryFilter, QueryOrder, Set, Statement, + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, + QueryOrder, Set, }; -use std::collections::HashSet; use uuid::Uuid; use crate::entities::{ @@ -109,6 +108,7 @@ impl SharingTagRepository { /// Delete a sharing tag by ID pub async fn delete(db: &DatabaseConnection, id: Uuid) -> Result { let result = SharingTags::delete_by_id(id).exec(db).await?; + crate::access_control_version::bump(); Ok(result.rows_affected > 0) } @@ -181,6 +181,7 @@ impl SharingTagRepository { .await?; if tag_ids.is_empty() { + crate::access_control_version::bump(); return Ok(vec![]); } @@ -192,6 +193,7 @@ impl SharingTagRepository { }; link.insert(db).await?; } + crate::access_control_version::bump(); // Return the tags let tags = SharingTags::find() @@ -227,6 +229,7 @@ impl SharingTagRepository { sharing_tag_id: Set(tag_id), }; link.insert(db).await?; + crate::access_control_version::bump(); Ok(true) } @@ -244,6 +247,7 @@ impl SharingTagRepository { .filter(series_sharing_tags::Column::SharingTagId.eq(tag_id)) .exec(db) .await?; + crate::access_control_version::bump(); Ok(result.rows_affected > 0) } @@ -500,6 +504,7 @@ impl SharingTagRepository { let mut active_model: user_sharing_tags::ActiveModel = existing.into(); active_model.access_mode = Set(access_mode.as_str().to_string()); let model = active_model.update(db).await?; + crate::access_control_version::bump(); Ok(model) } else { // Create new grant @@ -511,6 +516,7 @@ impl SharingTagRepository { created_at: Set(Utc::now()), }; let model = active_model.insert(db).await?; + crate::access_control_version::bump(); Ok(model) } } @@ -528,6 +534,7 @@ impl SharingTagRepository { .filter(user_sharing_tags::Column::SharingTagId.eq(tag_id)) .exec(db) .await?; + crate::access_control_version::bump(); Ok(result.rows_affected > 0) } @@ -541,6 +548,7 @@ impl SharingTagRepository { .filter(user_sharing_tags::Column::UserId.eq(user_id)) .exec(db) .await?; + crate::access_control_version::bump(); Ok(result.rows_affected) } @@ -561,225 +569,10 @@ impl SharingTagRepository { Ok(user_ids) } - // ==================== Content Filtering ==================== - - /// Check if a series is visible to a user based on sharing tags - /// - /// Visibility rules: - /// 1. If series has no sharing tags -> visible to everyone - /// 2. If series has sharing tags -> user needs at least one 'allow' grant for those tags - /// 3. If user has any 'deny' grant for any of the series' tags -> not visible - #[allow(dead_code)] - pub async fn is_series_visible_to_user( - db: &DatabaseConnection, - series_id: Uuid, - user_id: Uuid, - ) -> Result { - // Get series sharing tags - let series_tags = Self::get_tags_for_series(db, series_id).await?; - - // If series has no sharing tags, it's visible to everyone - if series_tags.is_empty() { - return Ok(true); - } - - let series_tag_ids: Vec = series_tags.iter().map(|t| t.id).collect(); - - // Get user's denied tags - let denied_tag_ids = Self::get_denied_tag_ids_for_user(db, user_id).await?; - - // If user has any deny grant for this series' tags, not visible - for tag_id in &series_tag_ids { - if denied_tag_ids.contains(tag_id) { - return Ok(false); - } - } - - // Get user's allowed tags - let allowed_tag_ids = Self::get_allowed_tag_ids_for_user(db, user_id).await?; - - // User needs at least one allow grant for the series' tags - for tag_id in &series_tag_ids { - if allowed_tag_ids.contains(tag_id) { - return Ok(true); - } - } - - // No allow grants match the series' tags - Ok(false) - } - - /// Get all series IDs that are visible to a user based on sharing tags - /// - /// This is used to filter series queries. Returns None if user has no - /// sharing tag restrictions (can see all content). - #[allow(dead_code)] - pub async fn get_visible_series_ids_for_user( - db: &DatabaseConnection, - user_id: Uuid, - ) -> Result>> { - use crate::entities::series_sharing_tags::Entity as SeriesSharingTags; - - // Get user grants - let allowed_tag_ids = Self::get_allowed_tag_ids_for_user(db, user_id).await?; - let denied_tag_ids = Self::get_denied_tag_ids_for_user(db, user_id).await?; - - // If user has no grants at all, they can see all unrestricted content - if allowed_tag_ids.is_empty() && denied_tag_ids.is_empty() { - return Ok(None); - } - - // Get all series that have sharing tags - let _all_tagged_series: std::collections::HashSet = SeriesSharingTags::find() - .all(db) - .await? - .into_iter() - .map(|st| st.series_id) - .collect(); - - // Get series IDs for denied tags (these are always hidden) - let mut denied_series: std::collections::HashSet = std::collections::HashSet::new(); - for tag_id in &denied_tag_ids { - let series_ids = Self::get_series_with_tag(db, *tag_id).await?; - denied_series.extend(series_ids); - } - - // Get series IDs for allowed tags - let mut allowed_series: std::collections::HashSet = std::collections::HashSet::new(); - for tag_id in &allowed_tag_ids { - let series_ids = Self::get_series_with_tag(db, *tag_id).await?; - allowed_series.extend(series_ids); - } - - // Remove denied series from allowed - allowed_series.retain(|id| !denied_series.contains(id)); - - // Series without sharing tags are visible to everyone - // But we need to exclude series that have tags the user doesn't have grants for - - // Result: allowed_series (from grants) - denied_series - // Plus: series without any sharing tags (which are always visible) - // We return None to indicate "no special filtering needed" if: - // - User has only deny grants (hide specific content but see everything else) - - // If user has only deny grants and no allow grants - if allowed_tag_ids.is_empty() && !denied_tag_ids.is_empty() { - // User can see all series except those with denied tags - // Return None to indicate broad access, but caller should filter out denied series - return Ok(None); - } - - Ok(Some(allowed_series.into_iter().collect())) - } - - /// Get effective sharing-tag grants for a user. - /// - /// Effective grants are the union of: - /// - The user's own `user_sharing_tags` rows (per-user overrides), and - /// - Every grant on every access group the user belongs to. - /// - /// The same tag may appear in both `allows` and `denies` (e.g. a group - /// allows it while the user denies it). Callers apply the deny-wins rule - /// downstream — this method intentionally surfaces the raw union so the - /// caller can decide policy. - /// - /// Returns `(allow_tag_ids, deny_tag_ids)`. Each vec is deduplicated. - pub async fn get_effective_grants( - db: &DatabaseConnection, - user_id: Uuid, - ) -> Result<(Vec, Vec)> { - let backend = db.get_database_backend(); - let sql = match backend { - DbBackend::Postgres => { - r#" - SELECT sharing_tag_id, access_mode - FROM user_sharing_tags - WHERE user_id = $1 - UNION - SELECT gst.sharing_tag_id, gst.access_mode - FROM user_access_groups uag - JOIN access_group_sharing_tags gst - ON gst.access_group_id = uag.access_group_id - WHERE uag.user_id = $2 - "# - } - _ => { - r#" - SELECT sharing_tag_id, access_mode - FROM user_sharing_tags - WHERE user_id = ? - UNION - SELECT gst.sharing_tag_id, gst.access_mode - FROM user_access_groups uag - JOIN access_group_sharing_tags gst - ON gst.access_group_id = uag.access_group_id - WHERE uag.user_id = ? - "# - } - }; - let stmt = - Statement::from_sql_and_values(backend, sql, vec![user_id.into(), user_id.into()]); - let rows = db - .query_all(stmt) - .await - .context("Failed to query effective sharing-tag grants")?; - - let mut allows: HashSet = HashSet::new(); - let mut denies: HashSet = HashSet::new(); - for row in rows { - let tag_id: Uuid = row.try_get("", "sharing_tag_id")?; - let mode: String = row.try_get("", "access_mode")?; - match mode.parse::().unwrap_or(AccessMode::Allow) { - AccessMode::Allow => { - allows.insert(tag_id); - } - AccessMode::Deny => { - denies.insert(tag_id); - } - } - } - Ok((allows.into_iter().collect(), denies.into_iter().collect())) - } - - /// Get series IDs to exclude for a user (series with denied tags) - pub async fn get_excluded_series_ids_for_user( - db: &DatabaseConnection, - user_id: Uuid, - ) -> Result> { - let denied_tag_ids = Self::get_denied_tag_ids_for_user(db, user_id).await?; - - let mut excluded_series = std::collections::HashSet::new(); - for tag_id in denied_tag_ids { - let series_ids = Self::get_series_with_tag(db, tag_id).await?; - excluded_series.extend(series_ids); - } - - Ok(excluded_series.into_iter().collect()) - } - - /// Get all series IDs that have any of the given tags - pub async fn get_series_ids_with_any_tags( - db: &DatabaseConnection, - tag_ids: &[Uuid], - ) -> Result> { - use crate::entities::series_sharing_tags::Entity as SeriesSharingTags; - - if tag_ids.is_empty() { - return Ok(vec![]); - } - - let series_ids: Vec = SeriesSharingTags::find() - .filter(series_sharing_tags::Column::SharingTagId.is_in(tag_ids.to_vec())) - .all(db) - .await? - .into_iter() - .map(|st| st.series_id) - .collect::>() - .into_iter() - .collect(); - - Ok(series_ids) - } + // ==================== Series lookups ==================== + // + // What a user may see is decided by `ContentFilter` in codex-services, + // not here. /// Get set of all series IDs that have any sharing tags pub async fn get_tagged_series_ids( @@ -1096,355 +889,4 @@ mod tests { .unwrap(); assert_eq!(grants.len(), 1); } - - #[tokio::test] - async fn test_series_visibility() { - let (db, _temp_dir) = create_test_db().await; - - let library = LibraryRepository::create( - db.sea_orm_connection(), - "Test Library", - "/test/path", - ScanningStrategy::Default, - ) - .await - .unwrap(); - - let user = create_test_user(db.sea_orm_connection(), "viewer").await; - - // Series without sharing tags is visible to everyone - let series_public = - SeriesRepository::create(db.sea_orm_connection(), library.id, "Public Series", None) - .await - .unwrap(); - - let visible = SharingTagRepository::is_series_visible_to_user( - db.sea_orm_connection(), - series_public.id, - user.id, - ) - .await - .unwrap(); - assert!(visible); - - // Create restricted series with a sharing tag - let kids_tag = SharingTagRepository::create(db.sea_orm_connection(), "Kids", None) - .await - .unwrap(); - - let series_restricted = - SeriesRepository::create(db.sea_orm_connection(), library.id, "Kids Series", None) - .await - .unwrap(); - - SharingTagRepository::add_tag_to_series( - db.sea_orm_connection(), - series_restricted.id, - kids_tag.id, - ) - .await - .unwrap(); - - // User without grant cannot see restricted series - let visible = SharingTagRepository::is_series_visible_to_user( - db.sea_orm_connection(), - series_restricted.id, - user.id, - ) - .await - .unwrap(); - assert!(!visible); - - // Grant user access - SharingTagRepository::set_user_grant( - db.sea_orm_connection(), - user.id, - kids_tag.id, - AccessMode::Allow, - ) - .await - .unwrap(); - - // Now user can see the series - let visible = SharingTagRepository::is_series_visible_to_user( - db.sea_orm_connection(), - series_restricted.id, - user.id, - ) - .await - .unwrap(); - assert!(visible); - - // Change to deny grant - SharingTagRepository::set_user_grant( - db.sea_orm_connection(), - user.id, - kids_tag.id, - AccessMode::Deny, - ) - .await - .unwrap(); - - // User cannot see the series anymore - let visible = SharingTagRepository::is_series_visible_to_user( - db.sea_orm_connection(), - series_restricted.id, - user.id, - ) - .await - .unwrap(); - assert!(!visible); - } - - #[tokio::test] - async fn test_get_effective_grants_user_only() { - let (db, _temp_dir) = create_test_db().await; - let conn = db.sea_orm_connection(); - let user = create_test_user(conn, "u").await; - let allow_tag = SharingTagRepository::create(conn, "allow", None) - .await - .unwrap(); - let deny_tag = SharingTagRepository::create(conn, "deny", None) - .await - .unwrap(); - - SharingTagRepository::set_user_grant(conn, user.id, allow_tag.id, AccessMode::Allow) - .await - .unwrap(); - SharingTagRepository::set_user_grant(conn, user.id, deny_tag.id, AccessMode::Deny) - .await - .unwrap(); - - let (allows, denies) = SharingTagRepository::get_effective_grants(conn, user.id) - .await - .unwrap(); - assert_eq!(allows, vec![allow_tag.id]); - assert_eq!(denies, vec![deny_tag.id]); - } - - #[tokio::test] - async fn test_get_effective_grants_group_only() { - use crate::entities::user_access_groups::MembershipSource; - use crate::repositories::AccessGroupRepository; - - let (db, _temp_dir) = create_test_db().await; - let conn = db.sea_orm_connection(); - let user = create_test_user(conn, "u").await; - let group = AccessGroupRepository::create(conn, "Group", None) - .await - .unwrap(); - let allow_tag = SharingTagRepository::create(conn, "manga", None) - .await - .unwrap(); - let deny_tag = SharingTagRepository::create(conn, "adult", None) - .await - .unwrap(); - - AccessGroupRepository::set_grant(conn, group.id, allow_tag.id, AccessMode::Allow) - .await - .unwrap(); - AccessGroupRepository::set_grant(conn, group.id, deny_tag.id, AccessMode::Deny) - .await - .unwrap(); - AccessGroupRepository::add_member(conn, group.id, user.id, MembershipSource::Manual) - .await - .unwrap(); - - let (allows, denies) = SharingTagRepository::get_effective_grants(conn, user.id) - .await - .unwrap(); - assert_eq!(allows, vec![allow_tag.id]); - assert_eq!(denies, vec![deny_tag.id]); - } - - #[tokio::test] - async fn test_get_effective_grants_user_and_group_merged() { - use crate::entities::user_access_groups::MembershipSource; - use crate::repositories::AccessGroupRepository; - - let (db, _temp_dir) = create_test_db().await; - let conn = db.sea_orm_connection(); - let user = create_test_user(conn, "u").await; - let group = AccessGroupRepository::create(conn, "Manga Readers", None) - .await - .unwrap(); - let manga = SharingTagRepository::create(conn, "manga", None) - .await - .unwrap(); - let adult = SharingTagRepository::create(conn, "18+", None) - .await - .unwrap(); - let news = SharingTagRepository::create(conn, "anime-news", None) - .await - .unwrap(); - - // Group: allow manga - AccessGroupRepository::set_grant(conn, group.id, manga.id, AccessMode::Allow) - .await - .unwrap(); - AccessGroupRepository::add_member(conn, group.id, user.id, MembershipSource::Manual) - .await - .unwrap(); - - // User: deny 18+, allow anime-news (per-user extra) - SharingTagRepository::set_user_grant(conn, user.id, adult.id, AccessMode::Deny) - .await - .unwrap(); - SharingTagRepository::set_user_grant(conn, user.id, news.id, AccessMode::Allow) - .await - .unwrap(); - - let (mut allows, denies) = SharingTagRepository::get_effective_grants(conn, user.id) - .await - .unwrap(); - allows.sort(); - let mut expected_allow = vec![manga.id, news.id]; - expected_allow.sort(); - assert_eq!(allows, expected_allow); - assert_eq!(denies, vec![adult.id]); - } - - #[tokio::test] - async fn test_get_effective_grants_deduplicates_across_sources() { - use crate::entities::user_access_groups::MembershipSource; - use crate::repositories::AccessGroupRepository; - - let (db, _temp_dir) = create_test_db().await; - let conn = db.sea_orm_connection(); - let user = create_test_user(conn, "u").await; - let g1 = AccessGroupRepository::create(conn, "G1", None) - .await - .unwrap(); - let g2 = AccessGroupRepository::create(conn, "G2", None) - .await - .unwrap(); - let tag = SharingTagRepository::create(conn, "shared", None) - .await - .unwrap(); - - // Same allow grant from two groups + user - AccessGroupRepository::set_grant(conn, g1.id, tag.id, AccessMode::Allow) - .await - .unwrap(); - AccessGroupRepository::set_grant(conn, g2.id, tag.id, AccessMode::Allow) - .await - .unwrap(); - SharingTagRepository::set_user_grant(conn, user.id, tag.id, AccessMode::Allow) - .await - .unwrap(); - AccessGroupRepository::add_member(conn, g1.id, user.id, MembershipSource::Manual) - .await - .unwrap(); - AccessGroupRepository::add_member(conn, g2.id, user.id, MembershipSource::Manual) - .await - .unwrap(); - - let (allows, denies) = SharingTagRepository::get_effective_grants(conn, user.id) - .await - .unwrap(); - assert_eq!(allows, vec![tag.id]); - assert!(denies.is_empty()); - } - - #[tokio::test] - async fn test_get_effective_grants_no_grants_no_groups() { - let (db, _temp_dir) = create_test_db().await; - let conn = db.sea_orm_connection(); - let user = create_test_user(conn, "u").await; - let (allows, denies) = SharingTagRepository::get_effective_grants(conn, user.id) - .await - .unwrap(); - assert!(allows.is_empty()); - assert!(denies.is_empty()); - } - - #[tokio::test] - async fn test_get_effective_grants_surfaces_both_allow_and_deny_for_same_tag() { - use crate::entities::user_access_groups::MembershipSource; - use crate::repositories::AccessGroupRepository; - - // Group allows the tag; user denies it. Both surface; deny-wins is - // applied by the caller (ContentFilter), not here. - let (db, _temp_dir) = create_test_db().await; - let conn = db.sea_orm_connection(); - let user = create_test_user(conn, "u").await; - let group = AccessGroupRepository::create(conn, "G", None) - .await - .unwrap(); - let tag = SharingTagRepository::create(conn, "tag", None) - .await - .unwrap(); - - AccessGroupRepository::set_grant(conn, group.id, tag.id, AccessMode::Allow) - .await - .unwrap(); - AccessGroupRepository::add_member(conn, group.id, user.id, MembershipSource::Manual) - .await - .unwrap(); - SharingTagRepository::set_user_grant(conn, user.id, tag.id, AccessMode::Deny) - .await - .unwrap(); - - let (allows, denies) = SharingTagRepository::get_effective_grants(conn, user.id) - .await - .unwrap(); - assert_eq!(allows, vec![tag.id]); - assert_eq!(denies, vec![tag.id]); - } - - #[tokio::test] - async fn test_excluded_series_for_user() { - let (db, _temp_dir) = create_test_db().await; - - let library = LibraryRepository::create( - db.sea_orm_connection(), - "Test Library", - "/test/path", - ScanningStrategy::Default, - ) - .await - .unwrap(); - - let user = create_test_user(db.sea_orm_connection(), "viewer").await; - - let adults_tag = SharingTagRepository::create(db.sea_orm_connection(), "Adults", None) - .await - .unwrap(); - - let series1 = - SeriesRepository::create(db.sea_orm_connection(), library.id, "Series 1", None) - .await - .unwrap(); - let series2 = - SeriesRepository::create(db.sea_orm_connection(), library.id, "Series 2", None) - .await - .unwrap(); - - // Tag series2 as adults only - SharingTagRepository::add_tag_to_series(db.sea_orm_connection(), series2.id, adults_tag.id) - .await - .unwrap(); - - // User has deny grant for adults tag - SharingTagRepository::set_user_grant( - db.sea_orm_connection(), - user.id, - adults_tag.id, - AccessMode::Deny, - ) - .await - .unwrap(); - - // Get excluded series - let excluded = SharingTagRepository::get_excluded_series_ids_for_user( - db.sea_orm_connection(), - user.id, - ) - .await - .unwrap(); - - assert_eq!(excluded.len(), 1); - assert!(excluded.contains(&series2.id)); - assert!(!excluded.contains(&series1.id)); - } } diff --git a/crates/codex-db/src/repositories/tag.rs b/crates/codex-db/src/repositories/tag.rs index c6449bfcc..c545147d6 100644 --- a/crates/codex-db/src/repositories/tag.rs +++ b/crates/codex-db/src/repositories/tag.rs @@ -4,6 +4,7 @@ #![allow(dead_code)] +use super::access_group_rules::normalize_taxonomy_name; use anyhow::Result; use chrono::Utc; use sea_orm::{ @@ -28,7 +29,7 @@ impl TagRepository { /// Get a tag by normalized name pub async fn get_by_name(db: &DatabaseConnection, name: &str) -> Result> { - let normalized = name.to_lowercase().trim().to_string(); + let normalized = normalize_taxonomy_name(name); let result = Tags::find() .filter(tags::Column::NormalizedName.eq(&normalized)) .one(db) @@ -47,7 +48,7 @@ impl TagRepository { /// Create a new tag pub async fn create(db: &DatabaseConnection, name: &str) -> Result { - let normalized = name.to_lowercase().trim().to_string(); + let normalized = normalize_taxonomy_name(name); let trimmed_name = name.trim().to_string(); let active_model = tags::ActiveModel { @@ -187,6 +188,37 @@ impl TagRepository { Ok(result.rows_affected > 0) } + /// Series count per tag, counting only series `visibility` lets through + /// (`None` counts every series). Tags with no counted series are absent. + pub async fn series_counts( + db: &DatabaseConnection, + visibility: Option<&crate::repositories::SeriesVisibility>, + ) -> Result> { + use sea_orm::QuerySelect; + + let mut query = series_tags::Entity::find() + .select_only() + .column(series_tags::Column::TagId) + .column_as(series_tags::Column::SeriesId.count(), "count") + .group_by(series_tags::Column::TagId); + if let Some(vis) = visibility { + if vis.is_empty_whitelist() { + return Ok(std::collections::HashMap::new()); + } + if let Some(predicate) = crate::repositories::visibility::visibility_predicate( + series_tags::Column::SeriesId, + vis, + ) { + query = query.filter(predicate); + } + } + let rows: Vec<(Uuid, i64)> = query.into_tuple().all(db).await?; + Ok(rows + .into_iter() + .map(|(id, count)| (id, count.max(0) as u64)) + .collect()) + } + /// Count series using a tag pub async fn count_series_with_tag(db: &DatabaseConnection, tag_id: Uuid) -> Result { use crate::entities::series_tags::Entity as SeriesTags; @@ -206,7 +238,7 @@ impl TagRepository { ) -> Result> { use crate::entities::series_tags::Entity as SeriesTags; - let normalized = tag_name.to_lowercase().trim().to_string(); + let normalized = normalize_taxonomy_name(tag_name); // First find the tag let tag = Tags::find() diff --git a/crates/codex-db/src/repositories/user.rs b/crates/codex-db/src/repositories/user.rs index d27175167..750beb1bd 100644 --- a/crates/codex-db/src/repositories/user.rs +++ b/crates/codex-db/src/repositories/user.rs @@ -1,4 +1,5 @@ use crate::entities::{sharing_tags, user_sharing_tags, users, users::Entity as User}; +use crate::repositories::AccessGroupRepository; use crate::trace::db_system_str; use anyhow::Result; use chrono::Utc; @@ -53,7 +54,13 @@ impl UserRepository { last_login_at: Set(model.last_login_at), }; - let result = active_model.insert(db).await?; + // The default-group membership is written in the same transaction, so + // an account can never exist without the baseline it was meant to get. + let txn = db.begin().await?; + let result = active_model.insert(&txn).await?; + AccessGroupRepository::join_default_group(&txn, result.id).await?; + txn.commit().await?; + crate::access_control_version::bump(); Ok(result) } @@ -156,6 +163,8 @@ impl UserRepository { updated_at: Set(Utc::now()), }; let result = active_model.update(db).await?; + // The role decides whether access groups apply at all. + crate::access_control_version::bump(); Ok(result) } @@ -172,6 +181,7 @@ impl UserRepository { )] pub async fn delete(db: &DatabaseConnection, id: Uuid) -> Result<()> { User::delete_by_id(id).exec(db).await?; + crate::access_control_version::bump(); Ok(()) } diff --git a/crates/codex-events/src/types.rs b/crates/codex-events/src/types.rs index 491a01a52..f0599fb44 100644 --- a/crates/codex-events/src/types.rs +++ b/crates/codex-events/src/types.rs @@ -286,6 +286,43 @@ impl EntityChangeEvent { } } + /// The series this event is about, when it names one directly. A book + /// cover event only carries the book's id; see [`Self::book_id`]. + pub fn series_id(&self) -> Option { + match &self.event { + EntityEvent::BookCreated { series_id, .. } + | EntityEvent::BookUpdated { series_id, .. } + | EntityEvent::BookDeleted { series_id, .. } + | EntityEvent::SeriesCreated { series_id, .. } + | EntityEvent::SeriesUpdated { series_id, .. } + | EntityEvent::SeriesDeleted { series_id, .. } + | EntityEvent::SeriesMetadataUpdated { series_id, .. } + | EntityEvent::SeriesBulkPurged { series_id, .. } + | EntityEvent::ReleaseAnnounced { series_id, .. } => Some(*series_id), + EntityEvent::CoverUpdated { + entity_type: EntityType::Series, + entity_id, + .. + } => Some(*entity_id), + _ => None, + } + } + + /// The book this event is about, if any. + pub fn book_id(&self) -> Option { + match &self.event { + EntityEvent::BookCreated { book_id, .. } + | EntityEvent::BookUpdated { book_id, .. } + | EntityEvent::BookDeleted { book_id, .. } => Some(*book_id), + EntityEvent::CoverUpdated { + entity_type: EntityType::Book, + entity_id, + .. + } => Some(*entity_id), + _ => None, + } + } + /// Create a shutdown signal event (internal use only) pub fn shutdown_signal() -> Self { Self { diff --git a/crates/codex-migrate/src/registry.rs b/crates/codex-migrate/src/registry.rs index e7d49f299..f1a32744c 100644 --- a/crates/codex-migrate/src/registry.rs +++ b/crates/codex-migrate/src/registry.rs @@ -85,8 +85,11 @@ macro_rules! for_each_entity { $op!(series_sharing_tags); $op!(sharing_tags); $op!(user_sharing_tags); + $op!(access_group_genre_rules); + $op!(access_group_library_rules); $op!(access_group_oidc_mappings); $op!(access_group_sharing_tags); + $op!(access_group_tag_rules); $op!(access_groups); $op!(user_access_groups); $op!(collection_series); diff --git a/crates/codex-services/src/content_filter.rs b/crates/codex-services/src/content_filter.rs index f05498a26..1c429f90b 100644 --- a/crates/codex-services/src/content_filter.rs +++ b/crates/codex-services/src/content_filter.rs @@ -1,89 +1,442 @@ -//! Content filter for sharing tag-based access control +//! Content filter: what a user is allowed to see. //! -//! This module provides content filtering based on user sharing tag grants. -//! It allows filtering series/books based on which sharing tags the user -//! has access to (allow) or is restricted from (deny). +//! A user's visibility comes from their access groups and their personal +//! sharing-tag grants. Each access group holds allow/deny rules on four +//! types: sharing tags, regular tags, genres and libraries. //! -//! ## Access Rules (in order of precedence) +//! ## Rules //! -//! 1. **Deny always wins**: If user has `deny` grant for ANY tag on a series → hidden -//! 2. **Whitelist mode** (user has any `allow` grants): User only sees series with allowed tags -//! 3. **No grants**: User sees everything (default-open behavior) - -use codex_db::repositories::{SeriesVisibility, SharingTagRepository}; -use sea_orm::DatabaseConnection; -use std::collections::HashSet; +//! 1. **Admins and maintainers see everything.** No group or grant applies to +//! them: maintainers curate metadata, covers and scans, which needs the +//! whole library. +//! 2. **Allows, per group.** In one group, values of the same type combine as +//! "any of" (or "all of", per the group's switch for that type; libraries +//! are always "any of"), and different types combine with AND. "Allow +//! library Manga + allow genre Kids" is Kids-genre series in Manga. +//! 3. **Allows, across groups: OR.** Each group grants its own scope, and the +//! user sees the union. Personal sharing-tag allows count as one more group. +//! 4. **Denies are global and always win.** Every deny, from every group and +//! from personal grants, is collected into one set and removed at the end. +//! A deny is not scoped to the group it is written in; that is what stops +//! another group from granting it back. +//! 5. **No allow anywhere** (or only deny-only groups): the user sees +//! everything except what is denied. +//! +//! visible = (union over groups with allows of: AND over types of: match) minus denies + +use codex_db::entities::{ + access_groups, access_groups::RuleMatch, user_access_groups, user_sharing_tags, + user_sharing_tags::AccessMode, +}; +use codex_db::repositories::{ + AccessGroupRules, AccessGroupRulesRepository, SeriesVisibility, UserRepository, +}; +use codex_models::permissions::UserRole; +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; +use std::collections::{HashMap, HashSet}; use uuid::Uuid; -/// Content filter based on user's sharing tag grants +/// Content filter based on the user's access groups and grants /// -/// This struct encapsulates the sharing tag access rules for a user -/// and can be used to filter content queries efficiently. +/// This struct encapsulates the access rules for a user and can be used to +/// filter content queries efficiently. #[derive(Debug, Clone, Default)] pub struct ContentFilter { - /// Series IDs that are explicitly excluded (user has deny grants for their tags) + /// Series IDs that are explicitly excluded (matched by a deny rule) pub excluded_series_ids: HashSet, /// Series IDs that user is allowed to see (when in whitelist mode) - /// Only populated when user has allow grants + /// Only populated when some group or grant allows something pub allowed_series_ids: Option>, - /// Whether user has any allow grants (triggers whitelist mode) + /// Whether any allow rule applies (triggers whitelist mode) pub whitelist_mode: bool, - /// Whether the user has any sharing tag restrictions at all + /// Whether the user has any restrictions at all pub has_restrictions: bool, + /// Libraries hidden by a library deny rule + pub hidden_library_ids: HashSet, + /// In whitelist mode, the libraries the user may see: those named by an + /// allow rule and those holding at least one visible series + pub visible_library_ids: Option>, +} + +/// Series IDs per rule target, resolved once for every group of a user. +#[derive(Default)] +struct ResolvedTargets { + sharing_tags: HashMap>, + tags: HashMap>, + genres: HashMap>, + libraries: HashMap>, } impl ContentFilter { - /// Create a content filter for a user based on their effective sharing tag grants. - /// - /// Effective grants are the union of the user's per-user `user_sharing_tags` - /// rows and grants attached to every access group the user belongs to. - /// Access groups can supply both `allow` and `deny` rules; users can - /// override either side. Downstream behavior (deny-wins, whitelist mode) - /// is unchanged — the only difference vs. the old implementation is the - /// data source feeding the rule set. + /// Build the content filter for a user. /// - /// The result can be reused for multiple queries in the same request. + /// Runs a fixed number of queries regardless of how many groups or rules + /// the user has. The result can be reused for multiple queries in the + /// same request. pub async fn for_user(db: &DatabaseConnection, user_id: Uuid) -> anyhow::Result { - let (allowed_tag_ids, denied_tag_ids) = - SharingTagRepository::get_effective_grants(db, user_id).await?; - - // Excluded series = series tagged with any effective deny tag. - let excluded_series_ids = if denied_tag_ids.is_empty() { - Vec::new() - } else { - SharingTagRepository::get_series_ids_with_any_tags(db, &denied_tag_ids).await? + let Some(user) = UserRepository::get_by_id(db, user_id).await? else { + // Fail closed: an unknown user sees nothing. + return Ok(Self::deny_all()); }; + if bypasses_content_filter(user.get_role()) { + return Ok(Self::allow_all()); + } - // Whitelist mode: any effective allow grant flips the user into - // "only-tagged" mode (untagged content disappears). - let whitelist_mode = !allowed_tag_ids.is_empty(); + let rule_sets: Vec = load_rule_sets(db, user_id) + .await? + .into_iter() + .map(|(_, rules)| rules) + .collect(); - let allowed_series_ids = if whitelist_mode { - let ids = - SharingTagRepository::get_series_ids_with_any_tags(db, &allowed_tag_ids).await?; - Some(ids.into_iter().collect()) + let targets = Self::resolve_targets(db, &rule_sets).await?; + + let mut excluded_series_ids: HashSet = HashSet::new(); + let mut allowed: HashSet = HashSet::new(); + let mut whitelist_mode = false; + + for rules in &rule_sets { + excluded_series_ids.extend(Self::denied_by(rules, &targets)); + if let Some(granted) = Self::granted_by(rules, &targets) { + whitelist_mode = true; + allowed.extend(granted); + } + } + + let hidden_library_ids: HashSet = rule_sets + .iter() + .flat_map(|r| r.libraries.iter()) + .filter(|r| r.access_mode == AccessMode::Deny) + .map(|r| r.library_id) + .collect(); + + let visible_library_ids = if whitelist_mode { + let visible_series: Vec = allowed + .iter() + .filter(|id| !excluded_series_ids.contains(id)) + .copied() + .collect(); + let mut libraries = + AccessGroupRulesRepository::library_ids_of_series(db, &visible_series).await?; + // A library an allow rule names is shown even before it has series. + libraries.extend( + rule_sets + .iter() + .filter(|r| r.has_allows()) + .flat_map(|r| r.libraries.iter()) + .filter(|r| r.access_mode == AccessMode::Allow) + .map(|r| r.library_id), + ); + Some(libraries) } else { None }; - let has_restrictions = !excluded_series_ids.is_empty() || whitelist_mode; - + let has_restrictions = + !excluded_series_ids.is_empty() || whitelist_mode || !hidden_library_ids.is_empty(); Ok(Self { - excluded_series_ids: excluded_series_ids.into_iter().collect(), - allowed_series_ids, + excluded_series_ids, + allowed_series_ids: whitelist_mode.then_some(allowed), whitelist_mode, has_restrictions, + hidden_library_ids, + visible_library_ids, + }) + } + + /// The user's filter, reused from the per-process cache while it is valid. + /// + /// Handlers should use this rather than [`Self::for_user`]: see + /// [`crate::content_filter_cache`] for when a cached filter is rebuilt. + pub async fn cached_for_user( + db: &DatabaseConnection, + user_id: Uuid, + ) -> anyhow::Result> { + crate::content_filter_cache::ContentFilterCache::global() + .get(db, user_id) + .await + } + + /// Explain whether a user can see a series, and why. + /// + /// Uses the same matching as [`Self::for_user`], fed with only this + /// series, so the verdict cannot disagree with what is enforced. + pub async fn explain_series( + db: &DatabaseConnection, + user_id: Uuid, + series_id: Uuid, + ) -> anyhow::Result { + use codex_db::repositories::{ + GenreRepository, LibraryRepository, SeriesRepository, SharingTagRepository, + TagRepository, normalize_taxonomy_name, + }; + + let unrestricted = SeriesAccessExplanation { + visible: true, + decision: AccessDecision::Unrestricted, + denied_by: Vec::new(), + allowed_by: Vec::new(), + }; + let Some(user) = UserRepository::get_by_id(db, user_id).await? else { + anyhow::bail!("User not found"); + }; + if bypasses_content_filter(user.get_role()) { + return Ok(unrestricted); + } + let Some(series) = SeriesRepository::get_by_id(db, series_id).await? else { + anyhow::bail!("Series not found"); + }; + + let sharing_tags = SharingTagRepository::get_tags_for_series(db, series_id).await?; + let tags = TagRepository::get_tags_for_series(db, series_id).await?; + let genres = GenreRepository::get_genres_for_series(db, series_id).await?; + let only = |_: ()| HashSet::from([series_id]); + let targets = ResolvedTargets { + sharing_tags: sharing_tags.iter().map(|t| (t.id, only(()))).collect(), + tags: tags + .iter() + .map(|t| (normalize_taxonomy_name(&t.name), only(()))) + .collect(), + genres: genres + .iter() + .map(|g| (normalize_taxonomy_name(&g.name), only(()))) + .collect(), + libraries: HashMap::from([(series.library_id, only(()))]), + }; + let sharing_tag_names: HashMap = + sharing_tags.into_iter().map(|t| (t.id, t.name)).collect(); + let library_name = LibraryRepository::get_by_id(db, series.library_id) + .await? + .map(|l| l.name) + .unwrap_or_default(); + + let rule_sets = load_rule_sets(db, user_id).await?; + let mut denied_by = Vec::new(); + let mut allowed_by = Vec::new(); + let mut whitelist = false; + for (source, rules) in &rule_sets { + for rule in rules + .sharing_tags + .iter() + .filter(|r| r.access_mode == AccessMode::Deny) + { + if targets.sharing_tags.contains_key(&rule.sharing_tag_id) { + denied_by.push(MatchedRule { + rule_type: RuleTypeLabel::SharingTag, + value: sharing_tag_names + .get(&rule.sharing_tag_id) + .cloned() + .unwrap_or_default(), + source: source.clone(), + }); + } + } + for (rule_type, list, map) in [ + (RuleTypeLabel::Tag, &rules.tags, &targets.tags), + (RuleTypeLabel::Genre, &rules.genres, &targets.genres), + ] { + for rule in list.iter().filter(|r| r.access_mode == AccessMode::Deny) { + if map.contains_key(&rule.normalized_name()) { + denied_by.push(MatchedRule { + rule_type, + value: rule.name.clone(), + source: source.clone(), + }); + } + } + } + for rule in rules + .libraries + .iter() + .filter(|r| r.access_mode == AccessMode::Deny) + { + if rule.library_id == series.library_id { + denied_by.push(MatchedRule { + rule_type: RuleTypeLabel::Library, + value: library_name.clone(), + source: source.clone(), + }); + } + } + if let Some(granted) = Self::granted_by(rules, &targets) { + whitelist = true; + if granted.contains(&series_id) { + allowed_by.push(source.clone()); + } + } + } + + let decision = if !denied_by.is_empty() { + AccessDecision::Denied + } else if !whitelist { + AccessDecision::Open + } else if allowed_by.is_empty() { + AccessDecision::NotAllowed + } else { + AccessDecision::Allowed + }; + Ok(SeriesAccessExplanation { + visible: matches!(decision, AccessDecision::Open | AccessDecision::Allowed), + decision, + denied_by, + allowed_by, }) } + /// Resolve every target named by any rule to its series, one query per type. + async fn resolve_targets( + db: &DatabaseConnection, + rule_sets: &[AccessGroupRules], + ) -> anyhow::Result { + let mut sharing_tag_ids = HashSet::new(); + let mut tag_names = HashSet::new(); + let mut genre_names = HashSet::new(); + let mut library_ids = HashSet::new(); + for rules in rule_sets { + sharing_tag_ids.extend(rules.sharing_tags.iter().map(|r| r.sharing_tag_id)); + tag_names.extend(rules.tags.iter().map(|r| r.normalized_name())); + genre_names.extend(rules.genres.iter().map(|r| r.normalized_name())); + library_ids.extend(rules.libraries.iter().map(|r| r.library_id)); + } + + let sharing_tag_ids: Vec = sharing_tag_ids.into_iter().collect(); + let tag_names: Vec = tag_names.into_iter().collect(); + let genre_names: Vec = genre_names.into_iter().collect(); + let library_ids: Vec = library_ids.into_iter().collect(); + + Ok(ResolvedTargets { + sharing_tags: AccessGroupRulesRepository::series_by_sharing_tag(db, &sharing_tag_ids) + .await?, + tags: AccessGroupRulesRepository::series_by_tag_name(db, &tag_names).await?, + genres: AccessGroupRulesRepository::series_by_genre_name(db, &genre_names).await?, + libraries: AccessGroupRulesRepository::series_by_library(db, &library_ids).await?, + }) + } + + /// Every series matched by any deny rule of this rule set. + fn denied_by(rules: &AccessGroupRules, targets: &ResolvedTargets) -> HashSet { + let mut denied = HashSet::new(); + let empty = HashSet::new(); + for rule in rules + .sharing_tags + .iter() + .filter(|r| r.access_mode == AccessMode::Deny) + { + denied.extend( + targets + .sharing_tags + .get(&rule.sharing_tag_id) + .unwrap_or(&empty), + ); + } + for rule in rules + .tags + .iter() + .filter(|r| r.access_mode == AccessMode::Deny) + { + denied.extend(targets.tags.get(&rule.normalized_name()).unwrap_or(&empty)); + } + for rule in rules + .genres + .iter() + .filter(|r| r.access_mode == AccessMode::Deny) + { + denied.extend( + targets + .genres + .get(&rule.normalized_name()) + .unwrap_or(&empty), + ); + } + for rule in rules + .libraries + .iter() + .filter(|r| r.access_mode == AccessMode::Deny) + { + denied.extend(targets.libraries.get(&rule.library_id).unwrap_or(&empty)); + } + denied + } + + /// What this rule set's allow rules grant, or `None` if it has none. + /// + /// Each type with allows yields one set (any/all per the group's switch), + /// and the sets of different types are intersected. + fn granted_by(rules: &AccessGroupRules, targets: &ResolvedTargets) -> Option> { + let empty = HashSet::new(); + let mut per_type: Vec> = Vec::new(); + + let sharing: Vec<&HashSet> = rules + .sharing_tags + .iter() + .filter(|r| r.access_mode == AccessMode::Allow) + .map(|r| { + targets + .sharing_tags + .get(&r.sharing_tag_id) + .unwrap_or(&empty) + }) + .collect(); + if !sharing.is_empty() { + per_type.push(combine(&sharing, rules.sharing_tag_match)); + } + + let tags: Vec<&HashSet> = rules + .tags + .iter() + .filter(|r| r.access_mode == AccessMode::Allow) + .map(|r| targets.tags.get(&r.normalized_name()).unwrap_or(&empty)) + .collect(); + if !tags.is_empty() { + per_type.push(combine(&tags, rules.tag_match)); + } + + let genres: Vec<&HashSet> = rules + .genres + .iter() + .filter(|r| r.access_mode == AccessMode::Allow) + .map(|r| targets.genres.get(&r.normalized_name()).unwrap_or(&empty)) + .collect(); + if !genres.is_empty() { + per_type.push(combine(&genres, rules.genre_match)); + } + + // A series is in one library, so "all of" can never match: always any. + let libraries: Vec<&HashSet> = rules + .libraries + .iter() + .filter(|r| r.access_mode == AccessMode::Allow) + .map(|r| targets.libraries.get(&r.library_id).unwrap_or(&empty)) + .collect(); + if !libraries.is_empty() { + per_type.push(combine(&libraries, RuleMatch::Any)); + } + + let mut iter = per_type.into_iter(); + let first = iter.next()?; + Some(iter.fold(first, |acc, set| acc.intersection(&set).copied().collect())) + } + + /// A filter under which nothing is visible. + fn deny_all() -> Self { + Self { + excluded_series_ids: HashSet::new(), + allowed_series_ids: Some(HashSet::new()), + whitelist_mode: true, + has_restrictions: true, + hidden_library_ids: HashSet::new(), + visible_library_ids: Some(HashSet::new()), + } + } + /// Create a content filter that allows all content (no restrictions) - #[allow(dead_code)] pub fn allow_all() -> Self { Self { excluded_series_ids: HashSet::new(), allowed_series_ids: None, whitelist_mode: false, has_restrictions: false, + hidden_library_ids: HashSet::new(), + visible_library_ids: None, } } @@ -110,6 +463,17 @@ impl ContentFilter { true } + /// Check if a library is visible to the user + pub fn is_library_visible(&self, library_id: Uuid) -> bool { + if self.hidden_library_ids.contains(&library_id) { + return false; + } + match &self.visible_library_ids { + Some(visible) => visible.contains(&library_id), + None => true, + } + } + /// Check if a book is visible to the user (based on its parent series) pub fn is_book_visible(&self, series_id: Uuid) -> bool { self.is_series_visible(series_id) @@ -151,6 +515,140 @@ impl ContentFilter { } } +/// The kind of value a rule names. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, utoipa::ToSchema, +)] +#[serde(rename_all = "camelCase")] +pub enum RuleTypeLabel { + SharingTag, + Tag, + Genre, + Library, +} + +/// One deny rule that matches a series. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MatchedRule { + pub rule_type: RuleTypeLabel, + /// Display value: the tag or genre name, or the sharing tag or library name + pub value: String, + pub source: RuleSource, +} + +/// The outcome for one series, and the reason for it. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, utoipa::ToSchema, +)] +#[serde(rename_all = "camelCase")] +pub enum AccessDecision { + /// Admin or maintainer: no rule applies + Unrestricted, + /// No allow rule applies to the user, and no deny matches + Open, + /// At least one group (or personal grant) lets it through, and no deny matches + Allowed, + /// The user's rules whitelist content and none of them matches this series + NotAllowed, + /// A deny rule matches; denies always win + Denied, +} + +/// Why a user can or cannot see a series. +#[derive(Debug, Clone)] +pub struct SeriesAccessExplanation { + pub visible: bool, + pub decision: AccessDecision, + /// Every deny rule that matches the series + pub denied_by: Vec, + /// Every rule set whose allow rules let the series through + pub allowed_by: Vec, +} + +/// Where a rule set comes from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RuleSource { + /// An access group the user is a member of + Group { id: Uuid, name: String }, + /// The user's personal sharing-tag grants + Personal, +} + +/// Every rule set that applies to a user: one per access group they are in, +/// plus their personal sharing-tag grants as one more. +async fn load_rule_sets( + db: &DatabaseConnection, + user_id: Uuid, +) -> anyhow::Result> { + let mut personal = AccessGroupRules::default(); + for grant in user_sharing_tags::Entity::find() + .filter(user_sharing_tags::Column::UserId.eq(user_id)) + .all(db) + .await? + { + personal + .sharing_tags + .push(codex_db::repositories::SharingTagRule { + sharing_tag_id: grant.sharing_tag_id, + access_mode: grant.get_access_mode(), + }); + } + + let group_ids: Vec = user_access_groups::Entity::find() + .filter(user_access_groups::Column::UserId.eq(user_id)) + .all(db) + .await? + .into_iter() + .map(|m| m.access_group_id) + .collect(); + let names: HashMap = access_groups::Entity::find() + .filter(access_groups::Column::Id.is_in(group_ids.clone())) + .all(db) + .await? + .into_iter() + .map(|g| (g.id, g.name)) + .collect(); + let mut sets: Vec<(RuleSource, AccessGroupRules)> = + AccessGroupRulesRepository::load_for_groups(db, &group_ids) + .await? + .into_iter() + .map(|(id, rules)| { + let name = names.get(&id).cloned().unwrap_or_default(); + (RuleSource::Group { id, name }, rules) + }) + .collect(); + // Stable order for explanations: groups by name, personal grants last. + sets.sort_by(|a, b| match (&a.0, &b.0) { + (RuleSource::Group { name: x, .. }, RuleSource::Group { name: y, .. }) => x.cmp(y), + (RuleSource::Group { .. }, RuleSource::Personal) => std::cmp::Ordering::Less, + (RuleSource::Personal, RuleSource::Group { .. }) => std::cmp::Ordering::Greater, + _ => std::cmp::Ordering::Equal, + }); + sets.push((RuleSource::Personal, personal)); + Ok(sets) +} + +/// Roles that access groups and sharing-tag grants never restrict. +pub fn bypasses_content_filter(role: UserRole) -> bool { + matches!(role, UserRole::Admin | UserRole::Maintainer) +} + +/// Combine the series sets of one rule type: union for "any", intersection for "all". +fn combine(sets: &[&HashSet], mode: RuleMatch) -> HashSet { + match mode { + RuleMatch::Any => sets.iter().flat_map(|s| s.iter().copied()).collect(), + RuleMatch::All => { + let mut iter = sets.iter(); + let Some(first) = iter.next() else { + return HashSet::new(); + }; + iter.fold((*first).clone(), |acc, set| { + acc.intersection(set).copied().collect() + }) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -177,6 +675,7 @@ mod tests { allowed_series_ids: None, whitelist_mode: false, has_restrictions: true, + ..Default::default() }; assert!(filter.is_series_visible(allowed)); @@ -194,6 +693,7 @@ mod tests { allowed_series_ids: Some([allowed_tagged_series].into_iter().collect()), whitelist_mode: true, has_restrictions: true, + ..Default::default() }; // Untagged series should NOT be visible in whitelist mode @@ -212,6 +712,7 @@ mod tests { allowed_series_ids: Some([allowed_series].into_iter().collect()), whitelist_mode: true, has_restrictions: true, + ..Default::default() }; assert!(filter.is_series_visible(allowed_series)); @@ -228,6 +729,7 @@ mod tests { allowed_series_ids: Some([allowed_series].into_iter().collect()), whitelist_mode: true, has_restrictions: true, + ..Default::default() }; assert!(filter.is_series_visible(allowed_series)); @@ -244,6 +746,7 @@ mod tests { allowed_series_ids: Some([mixed_series].into_iter().collect()), whitelist_mode: true, has_restrictions: true, + ..Default::default() }; // Deny wins, so series should be hidden @@ -261,6 +764,7 @@ mod tests { allowed_series_ids: None, whitelist_mode: false, has_restrictions: false, + ..Default::default() }; assert!(filter.is_series_visible(any_series)); @@ -278,6 +782,7 @@ mod tests { allowed_series_ids: Some([allowed].into_iter().collect()), whitelist_mode: true, has_restrictions: true, + ..Default::default() }; let input = vec![allowed, excluded, untagged]; @@ -300,6 +805,7 @@ mod tests { allowed_series_ids: None, whitelist_mode: false, has_restrictions: true, + ..Default::default() }; assert!(filter.is_book_visible(allowed_series)); @@ -312,7 +818,7 @@ mod db_tests { //! DB-backed integration tests for the group-aware `ContentFilter`. //! //! These exercise the full path from `user_sharing_tags` and - //! `user_access_groups` rows through `get_effective_grants` into the + //! `user_access_groups` rows and group rules into the //! filter's boolean output. The pure unit tests above cover the //! in-memory rules; here we cover the data wiring. @@ -640,6 +1146,790 @@ mod db_tests { assert!(filter.is_series_visible(series.id)); } + // ---------- Rules on tags, genres and libraries ---------- + + use codex_db::entities::access_groups::RuleMatch; + use codex_db::repositories::{ + AccessGroupRules, AccessGroupRulesRepository, GenreRepository, LibraryRule, NameRule, + SharingTagRule, TagRepository, + }; + + struct Fixture { + db: codex_db::Database, + _temp_dir: tempfile::TempDir, + } + + impl Fixture { + async fn new() -> Self { + let (db, _temp_dir) = create_test_db().await; + Self { db, _temp_dir } + } + + fn conn(&self) -> &DatabaseConnection { + self.db.sea_orm_connection() + } + + async fn library(&self, name: &str) -> Uuid { + LibraryRepository::create( + self.conn(), + name, + &format!("/tmp/{name}"), + ScanningStrategy::Default, + ) + .await + .unwrap() + .id + } + + async fn series(&self, library_id: Uuid, name: &str, genres: &[&str]) -> Uuid { + let series = SeriesRepository::create(self.conn(), library_id, name, None) + .await + .unwrap(); + for genre in genres { + GenreRepository::add_genre_to_series(self.conn(), series.id, genre) + .await + .unwrap(); + } + series.id + } + + async fn group(&self, name: &str, user_id: Uuid, rules: AccessGroupRules) -> Uuid { + let group = AccessGroupRepository::create(self.conn(), name, None) + .await + .unwrap(); + AccessGroupRulesRepository::replace(self.conn(), group.id, &rules) + .await + .unwrap(); + AccessGroupRepository::add_member( + self.conn(), + group.id, + user_id, + MembershipSource::Manual, + ) + .await + .unwrap(); + group.id + } + + async fn filter(&self, user_id: Uuid) -> ContentFilter { + ContentFilter::for_user(self.conn(), user_id).await.unwrap() + } + } + + fn allow_library(library_id: Uuid) -> LibraryRule { + LibraryRule { + library_id, + access_mode: AccessMode::Allow, + } + } + + fn deny_library(library_id: Uuid) -> LibraryRule { + LibraryRule { + library_id, + access_mode: AccessMode::Deny, + } + } + + async fn make_with_role(db: &DatabaseConnection, username: &str, role: &str) -> Uuid { + let id = make_user(db, username).await; + let mut user = UserRepository::get_by_id(db, id).await.unwrap().unwrap(); + user.role = role.to_string(); + UserRepository::update(db, &user).await.unwrap(); + id + } + + async fn make_admin(db: &DatabaseConnection, username: &str) -> Uuid { + make_with_role(db, username, "admin").await + } + + // ---------- Explanations ---------- + + use super::{AccessDecision, RuleSource, RuleTypeLabel}; + + #[tokio::test] + async fn explains_a_deny_with_its_group() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "kid").await; + let lib = f.library("Manga").await; + let adult = f.series(lib, "Adult", &["Adult"]).await; + let group = f + .group( + "Kids", + user, + AccessGroupRules { + genres: vec![NameRule::new("Adult", AccessMode::Deny)], + ..Default::default() + }, + ) + .await; + + let explanation = ContentFilter::explain_series(f.conn(), user, adult) + .await + .unwrap(); + assert!(!explanation.visible); + assert_eq!(explanation.decision, AccessDecision::Denied); + assert_eq!(explanation.denied_by.len(), 1); + let rule = &explanation.denied_by[0]; + assert_eq!(rule.rule_type, RuleTypeLabel::Genre); + assert_eq!(rule.value, "Adult"); + assert_eq!( + rule.source, + RuleSource::Group { + id: group, + name: "Kids".to_string() + } + ); + } + + #[tokio::test] + async fn explains_a_series_no_allow_rule_matches() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "kid").await; + let kids = f.library("Kids").await; + let manga = f.library("Manga").await; + f.series(kids, "Bluey", &[]).await; + let berserk = f.series(manga, "Berserk", &[]).await; + f.group( + "Kids", + user, + AccessGroupRules { + libraries: vec![allow_library(kids)], + ..Default::default() + }, + ) + .await; + + let explanation = ContentFilter::explain_series(f.conn(), user, berserk) + .await + .unwrap(); + assert!(!explanation.visible); + assert_eq!(explanation.decision, AccessDecision::NotAllowed); + assert!(explanation.denied_by.is_empty()); + assert!(explanation.allowed_by.is_empty()); + } + + #[tokio::test] + async fn explains_which_group_lets_a_series_through() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "kid").await; + let kids = f.library("Kids").await; + let bluey = f.series(kids, "Bluey", &[]).await; + let group = f + .group( + "Kids", + user, + AccessGroupRules { + libraries: vec![allow_library(kids)], + ..Default::default() + }, + ) + .await; + + let explanation = ContentFilter::explain_series(f.conn(), user, bluey) + .await + .unwrap(); + assert!(explanation.visible); + assert_eq!(explanation.decision, AccessDecision::Allowed); + assert_eq!( + explanation.allowed_by, + vec![RuleSource::Group { + id: group, + name: "Kids".to_string() + }] + ); + } + + #[tokio::test] + async fn explains_bypass_roles() { + let f = Fixture::new().await; + let admin = make_admin(f.conn(), "admin").await; + let lib = f.library("Any").await; + let series = f.series(lib, "S", &["Adult"]).await; + let explanation = ContentFilter::explain_series(f.conn(), admin, series) + .await + .unwrap(); + assert!(explanation.visible); + assert_eq!(explanation.decision, AccessDecision::Unrestricted); + } + + /// The explanation must never disagree with what is enforced. + #[tokio::test] + async fn explanations_agree_with_the_filter() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "reader").await; + let manga = f.library("Manga").await; + let comics = f.library("Comics").await; + let secret = f.library("Secret").await; + let mut all = Vec::new(); + for (lib, name, genres) in [ + (manga, "Kids manga", vec!["Kids"]), + (manga, "Adult manga", vec!["Adult", "Kids"]), + (manga, "Action manga", vec!["Action"]), + (comics, "Kids comic", vec!["Kids", "Comedy"]), + (comics, "Comedy comic", vec!["Comedy"]), + (secret, "Secret kids", vec!["Kids"]), + ] { + all.push(f.series(lib, name, &genres).await); + } + let tag = SharingTagRepository::create(f.conn(), "shared", None) + .await + .unwrap(); + SharingTagRepository::add_tag_to_series(f.conn(), all[2], tag.id) + .await + .unwrap(); + SharingTagRepository::set_user_grant(f.conn(), user, tag.id, AccessMode::Allow) + .await + .unwrap(); + f.group( + "Kids manga", + user, + AccessGroupRules { + genres: vec![NameRule::new("Kids", AccessMode::Allow)], + libraries: vec![allow_library(manga)], + ..Default::default() + }, + ) + .await; + f.group( + "Comics, no adult", + user, + AccessGroupRules { + genre_match: RuleMatch::All, + genres: vec![ + NameRule::new("Kids", AccessMode::Allow), + NameRule::new("Comedy", AccessMode::Allow), + NameRule::new("Adult", AccessMode::Deny), + ], + libraries: vec![allow_library(comics), deny_library(secret)], + ..Default::default() + }, + ) + .await; + + let filter = f.filter(user).await; + for series in all { + let explanation = ContentFilter::explain_series(f.conn(), user, series) + .await + .unwrap(); + assert_eq!( + explanation.visible, + filter.is_series_visible(series), + "{explanation:?}" + ); + } + } + + // ---------- Library visibility ---------- + + #[tokio::test] + async fn a_denied_library_is_hidden() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "kid").await; + let adult = f.library("Adult").await; + let manga = f.library("Manga").await; + f.group( + "No adult library", + user, + AccessGroupRules { + libraries: vec![deny_library(adult)], + ..Default::default() + }, + ) + .await; + + let filter = f.filter(user).await; + assert!(!filter.is_library_visible(adult)); + assert!( + filter.is_library_visible(manga), + "open mode keeps other libraries" + ); + } + + /// In whitelist mode a library is listed only if something in it is visible. + #[tokio::test] + async fn whitelist_mode_lists_only_libraries_with_visible_series() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "kid").await; + let manga = f.library("Manga").await; + let comics = f.library("Comics").await; + let empty = f.library("Empty").await; + f.series(manga, "Kids manga", &["Kids"]).await; + f.series(comics, "Action comic", &["Action"]).await; + f.group( + "Kids genre", + user, + AccessGroupRules { + genres: vec![NameRule::new("Kids", AccessMode::Allow)], + ..Default::default() + }, + ) + .await; + + let filter = f.filter(user).await; + assert!(filter.is_library_visible(manga)); + assert!(!filter.is_library_visible(comics)); + assert!(!filter.is_library_visible(empty)); + } + + /// A library allow shows the library even before it has any series. + #[tokio::test] + async fn an_allowed_library_is_listed_even_when_empty() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "kid").await; + let kids = f.library("Kids").await; + let manga = f.library("Manga").await; + f.group( + "Kids", + user, + AccessGroupRules { + libraries: vec![allow_library(kids)], + ..Default::default() + }, + ) + .await; + + let filter = f.filter(user).await; + assert!(filter.is_library_visible(kids)); + assert!(!filter.is_library_visible(manga)); + } + + #[tokio::test] + async fn no_rules_shows_every_library() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "reader").await; + let lib = f.library("Any").await; + assert!(f.filter(user).await.is_library_visible(lib)); + } + + /// Maintainers curate content (metadata, scans, covers), so they see all of it. + #[tokio::test] + async fn maintainers_bypass_every_rule() { + let f = Fixture::new().await; + let maintainer = make_with_role(f.conn(), "curator", "maintainer").await; + let lib = f.library("Adult").await; + let adult = f.series(lib, "Adult", &["Adult"]).await; + f.group( + "Locked", + maintainer, + AccessGroupRules { + genres: vec![NameRule::new("Adult", AccessMode::Deny)], + libraries: vec![deny_library(lib)], + ..Default::default() + }, + ) + .await; + + let filter = f.filter(maintainer).await; + assert!(!filter.has_restrictions); + assert!(filter.is_series_visible(adult)); + } + + #[tokio::test] + async fn admins_bypass_every_rule() { + let f = Fixture::new().await; + let admin = make_admin(f.conn(), "admin").await; + let lib = f.library("Adult").await; + let adult = f.series(lib, "Adult", &["Adult"]).await; + let tag = SharingTagRepository::create(f.conn(), "18+", None) + .await + .unwrap(); + SharingTagRepository::add_tag_to_series(f.conn(), adult, tag.id) + .await + .unwrap(); + SharingTagRepository::set_user_grant(f.conn(), admin, tag.id, AccessMode::Deny) + .await + .unwrap(); + f.group( + "Locked", + admin, + AccessGroupRules { + genres: vec![NameRule::new("Adult", AccessMode::Deny)], + libraries: vec![deny_library(lib)], + ..Default::default() + }, + ) + .await; + + let filter = f.filter(admin).await; + assert!(!filter.has_restrictions); + assert!(filter.is_series_visible(adult)); + } + + #[tokio::test] + async fn genre_deny_hides_without_starting_a_whitelist() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "kid").await; + let lib = f.library("Manga").await; + let adult = f.series(lib, "Adult", &["Adult", "Action"]).await; + let action = f.series(lib, "Action", &["Action"]).await; + let untagged = f.series(lib, "Untagged", &[]).await; + f.group( + "No adult", + user, + AccessGroupRules { + genres: vec![NameRule::new("adult", AccessMode::Deny)], + ..Default::default() + }, + ) + .await; + + let filter = f.filter(user).await; + assert!( + !filter.whitelist_mode, + "a deny-only group must not whitelist" + ); + assert!( + !filter.is_series_visible(adult), + "matched case-insensitively" + ); + assert!(filter.is_series_visible(action)); + assert!(filter.is_series_visible(untagged)); + } + + #[tokio::test] + async fn tag_deny_matches_by_name() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "kid").await; + let lib = f.library("Manga").await; + let gory = f.series(lib, "Gory", &[]).await; + let clean = f.series(lib, "Clean", &[]).await; + TagRepository::add_tag_to_series(f.conn(), gory, "Gore") + .await + .unwrap(); + f.group( + "No gore", + user, + AccessGroupRules { + tags: vec![NameRule::new("GORE", AccessMode::Deny)], + ..Default::default() + }, + ) + .await; + + let filter = f.filter(user).await; + assert!(!filter.is_series_visible(gory)); + assert!(filter.is_series_visible(clean)); + } + + #[tokio::test] + async fn library_allow_whitelists_the_whole_library() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "kid").await; + let kids_lib = f.library("Kids").await; + let manga_lib = f.library("Manga").await; + let in_kids = f.series(kids_lib, "Bluey", &[]).await; + let in_manga = f.series(manga_lib, "Berserk", &[]).await; + f.group( + "Kids", + user, + AccessGroupRules { + libraries: vec![allow_library(kids_lib)], + ..Default::default() + }, + ) + .await; + + let filter = f.filter(user).await; + assert!(filter.whitelist_mode); + assert!( + filter.is_series_visible(in_kids), + "no tags needed inside the library" + ); + assert!(!filter.is_series_visible(in_manga)); + } + + #[tokio::test] + async fn library_deny_hides_the_library() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "kid").await; + let adult_lib = f.library("Adult").await; + let manga_lib = f.library("Manga").await; + let in_adult = f.series(adult_lib, "Adult", &[]).await; + let in_manga = f.series(manga_lib, "Manga", &[]).await; + f.group( + "Hide adult library", + user, + AccessGroupRules { + libraries: vec![deny_library(adult_lib)], + ..Default::default() + }, + ) + .await; + + let filter = f.filter(user).await; + assert!(!filter.is_series_visible(in_adult)); + assert!(filter.is_series_visible(in_manga)); + } + + /// Different rule types in one group narrow each other. + #[tokio::test] + async fn types_within_a_group_combine_with_and() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "kid").await; + let manga_lib = f.library("Manga").await; + let comics_lib = f.library("Comics").await; + let kids_manga = f.series(manga_lib, "Kids manga", &["Kids"]).await; + let other_manga = f.series(manga_lib, "Other manga", &["Action"]).await; + let kids_comic = f.series(comics_lib, "Kids comic", &["Kids"]).await; + f.group( + "Kids manga", + user, + AccessGroupRules { + genres: vec![NameRule::new("Kids", AccessMode::Allow)], + libraries: vec![allow_library(manga_lib)], + ..Default::default() + }, + ) + .await; + + let filter = f.filter(user).await; + assert!(filter.is_series_visible(kids_manga)); + assert!(!filter.is_series_visible(other_manga)); + assert!(!filter.is_series_visible(kids_comic)); + } + + /// Each group grants its own scope; a user in two groups gets both. + #[tokio::test] + async fn groups_combine_with_or() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "reader").await; + let manga_lib = f.library("Manga").await; + let comics_lib = f.library("Comics").await; + let action_manga = f.series(manga_lib, "Action manga", &["Action"]).await; + let kids_comic = f.series(comics_lib, "Kids comic", &["Kids"]).await; + let other_comic = f.series(comics_lib, "Other comic", &["Action"]).await; + f.group( + "Manga", + user, + AccessGroupRules { + libraries: vec![allow_library(manga_lib)], + ..Default::default() + }, + ) + .await; + f.group( + "Kids comics", + user, + AccessGroupRules { + genres: vec![NameRule::new("Kids", AccessMode::Allow)], + libraries: vec![allow_library(comics_lib)], + ..Default::default() + }, + ) + .await; + + let filter = f.filter(user).await; + assert!( + filter.is_series_visible(action_manga), + "the genre rule of one group must not narrow another group" + ); + assert!(filter.is_series_visible(kids_comic)); + assert!(!filter.is_series_visible(other_comic)); + } + + /// A deny written in one group applies to what another group grants. + #[tokio::test] + async fn denies_apply_across_groups() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "reader").await; + let manga_lib = f.library("Manga").await; + let comics_lib = f.library("Comics").await; + let adult_manga = f.series(manga_lib, "Adult manga", &["Adult"]).await; + let manga = f.series(manga_lib, "Manga", &["Action"]).await; + let comic = f.series(comics_lib, "Comic", &[]).await; + f.group( + "Manga", + user, + AccessGroupRules { + libraries: vec![allow_library(manga_lib)], + ..Default::default() + }, + ) + .await; + f.group( + "Comics, no adult", + user, + AccessGroupRules { + genres: vec![NameRule::new("Adult", AccessMode::Deny)], + libraries: vec![allow_library(comics_lib)], + ..Default::default() + }, + ) + .await; + + let filter = f.filter(user).await; + assert!(!filter.is_series_visible(adult_manga)); + assert!(filter.is_series_visible(manga)); + assert!(filter.is_series_visible(comic)); + } + + #[tokio::test] + async fn genre_match_all_requires_every_allowed_genre() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "kid").await; + let lib = f.library("Manga").await; + let both = f.series(lib, "Both", &["Kids", "Comedy"]).await; + let kids_only = f.series(lib, "Kids only", &["Kids"]).await; + f.group( + "Kids comedy", + user, + AccessGroupRules { + genre_match: RuleMatch::All, + genres: vec![ + NameRule::new("Kids", AccessMode::Allow), + NameRule::new("Comedy", AccessMode::Allow), + ], + ..Default::default() + }, + ) + .await; + + let filter = f.filter(user).await; + assert!(filter.is_series_visible(both)); + assert!(!filter.is_series_visible(kids_only)); + } + + #[tokio::test] + async fn genre_match_any_is_the_default() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "kid").await; + let lib = f.library("Manga").await; + let kids = f.series(lib, "Kids", &["Kids"]).await; + let comedy = f.series(lib, "Comedy", &["Comedy"]).await; + let action = f.series(lib, "Action", &["Action"]).await; + f.group( + "Kids or comedy", + user, + AccessGroupRules { + genres: vec![ + NameRule::new("Kids", AccessMode::Allow), + NameRule::new("Comedy", AccessMode::Allow), + ], + ..Default::default() + }, + ) + .await; + + let filter = f.filter(user).await; + assert!(filter.is_series_visible(kids)); + assert!(filter.is_series_visible(comedy)); + assert!(!filter.is_series_visible(action)); + } + + #[tokio::test] + async fn sharing_tag_match_all_requires_every_allowed_tag() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "kid").await; + let lib = f.library("Manga").await; + let both = f.series(lib, "Both", &[]).await; + let one = f.series(lib, "One", &[]).await; + let a = SharingTagRepository::create(f.conn(), "a", None) + .await + .unwrap(); + let b = SharingTagRepository::create(f.conn(), "b", None) + .await + .unwrap(); + for (series, tag) in [(both, a.id), (both, b.id), (one, a.id)] { + SharingTagRepository::add_tag_to_series(f.conn(), series, tag) + .await + .unwrap(); + } + f.group( + "A and B", + user, + AccessGroupRules { + sharing_tag_match: RuleMatch::All, + sharing_tags: vec![ + SharingTagRule { + sharing_tag_id: a.id, + access_mode: AccessMode::Allow, + }, + SharingTagRule { + sharing_tag_id: b.id, + access_mode: AccessMode::Allow, + }, + ], + ..Default::default() + }, + ) + .await; + + let filter = f.filter(user).await; + assert!(filter.is_series_visible(both)); + assert!(!filter.is_series_visible(one)); + } + + /// Personal sharing-tag allows act as one more group. + #[tokio::test] + async fn personal_allows_add_to_group_allows() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "kid").await; + let kids_lib = f.library("Kids").await; + let manga_lib = f.library("Manga").await; + let in_kids = f.series(kids_lib, "Bluey", &[]).await; + let shared_manga = f.series(manga_lib, "Shared", &[]).await; + let other_manga = f.series(manga_lib, "Other", &[]).await; + let tag = SharingTagRepository::create(f.conn(), "shared", None) + .await + .unwrap(); + SharingTagRepository::add_tag_to_series(f.conn(), shared_manga, tag.id) + .await + .unwrap(); + SharingTagRepository::set_user_grant(f.conn(), user, tag.id, AccessMode::Allow) + .await + .unwrap(); + f.group( + "Kids", + user, + AccessGroupRules { + libraries: vec![allow_library(kids_lib)], + ..Default::default() + }, + ) + .await; + + let filter = f.filter(user).await; + assert!(filter.is_series_visible(in_kids)); + assert!(filter.is_series_visible(shared_manga)); + assert!(!filter.is_series_visible(other_manga)); + } + + /// A genre removed by cleanup and recreated under a new id is still denied. + #[tokio::test] + async fn genre_deny_survives_genre_recreation() { + let f = Fixture::new().await; + let user = make_user(f.conn(), "kid").await; + let lib = f.library("Manga").await; + f.group( + "No adult", + user, + AccessGroupRules { + genres: vec![NameRule::new("Adult", AccessMode::Deny)], + ..Default::default() + }, + ) + .await; + let first = f.series(lib, "First", &["Adult"]).await; + let first_genre = GenreRepository::get_by_name(f.conn(), "Adult") + .await + .unwrap() + .unwrap(); + GenreRepository::remove_genre_from_series(f.conn(), first, first_genre.id) + .await + .unwrap(); + GenreRepository::delete_unused(f.conn()).await.unwrap(); + + let second = f.series(lib, "Second", &["Adult"]).await; + let second_genre = GenreRepository::get_by_name(f.conn(), "Adult") + .await + .unwrap() + .unwrap(); + assert_ne!(first_genre.id, second_genre.id, "genre was recreated"); + + let filter = f.filter(user).await; + assert!(!filter.is_series_visible(second)); + } + /// Smoke check: building a filter for a user in 10 groups with ~50 grants /// each (~500 group grants total) should be well under 50ms on SQLite. /// Not a hard performance contract — just catches O(n*m) regressions. diff --git a/crates/codex-services/src/content_filter_cache.rs b/crates/codex-services/src/content_filter_cache.rs new file mode 100644 index 000000000..b98a1df57 --- /dev/null +++ b/crates/codex-services/src/content_filter_cache.rs @@ -0,0 +1,342 @@ +//! Per-user cache of built content filters. +//! +//! Building a [`ContentFilter`] takes several queries, and readers hit it on +//! every page image during webtoon prefetch. Each process keeps the built +//! filter per user for a short time instead. +//! +//! An entry is used only while both hold: +//! - it is younger than the TTL (30 seconds by default), and +//! - it was built under the current [`access_control_version`], which every +//! repository write that changes visibility bumps. +//! +//! So a rule, membership, grant or role change in this process applies on the +//! next request, another process catches up within the TTL, and a genre or tag +//! change from a scan applies within the TTL. Entries can be evicted at any +//! time (size cap, expiry, restart): a miss just rebuilds from the database. +//! +//! [`access_control_version`]: codex_db::access_control_version + +use crate::content_filter::ContentFilter; +use codex_db::access_control_version; +use lru::LruCache; +use sea_orm::DatabaseConnection; +use std::num::NonZeroUsize; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; +use uuid::Uuid; + +/// How long a built filter is reused. +pub const DEFAULT_TTL: Duration = Duration::from_secs(30); + +/// How many users' filters are kept at once. +pub const DEFAULT_CAPACITY: usize = 1024; + +struct Entry { + version: u64, + built_at: Instant, + filter: Arc, +} + +pub struct ContentFilterCache { + ttl: Duration, + entries: Mutex>, +} + +impl ContentFilterCache { + pub fn new(capacity: usize, ttl: Duration) -> Self { + let capacity = NonZeroUsize::new(capacity).unwrap_or(NonZeroUsize::MIN); + Self { + ttl, + entries: Mutex::new(LruCache::new(capacity)), + } + } + + /// The process-wide cache. + pub fn global() -> &'static Self { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| Self::new(DEFAULT_CAPACITY, DEFAULT_TTL)) + } + + /// The user's filter, from the cache when it is still valid. + pub async fn get( + &self, + db: &DatabaseConnection, + user_id: Uuid, + ) -> anyhow::Result> { + // Read the version before building: a change that lands while the + // filter is being built leaves it tagged with the older version, so + // the next request rebuilds. + let version = access_control_version::current(); + if let Some(filter) = self.lookup(user_id, version, Instant::now()) { + return Ok(filter); + } + let filter = Arc::new(ContentFilter::for_user(db, user_id).await?); + self.store(user_id, version, Instant::now(), filter.clone()); + Ok(filter) + } + + fn lookup(&self, user_id: Uuid, version: u64, now: Instant) -> Option> { + let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner()); + let valid = entries.get(&user_id).is_some_and(|entry| { + entry.version == version && now.duration_since(entry.built_at) < self.ttl + }); + if valid { + entries.get(&user_id).map(|e| e.filter.clone()) + } else { + entries.pop(&user_id); + None + } + } + + fn store(&self, user_id: Uuid, version: u64, built_at: Instant, filter: Arc) { + let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner()); + entries.put( + user_id, + Entry { + version, + built_at, + filter, + }, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn filter() -> Arc { + Arc::new(ContentFilter::allow_all()) + } + + #[test] + fn a_fresh_entry_is_reused() { + let cache = ContentFilterCache::new(8, Duration::from_secs(30)); + let user = Uuid::new_v4(); + let now = Instant::now(); + let built = filter(); + cache.store(user, 7, now, built.clone()); + + let hit = cache + .lookup(user, 7, now + Duration::from_secs(29)) + .unwrap(); + assert!(Arc::ptr_eq(&hit, &built)); + } + + #[test] + fn an_expired_entry_is_dropped() { + let cache = ContentFilterCache::new(8, Duration::from_secs(30)); + let user = Uuid::new_v4(); + let now = Instant::now(); + cache.store(user, 7, now, filter()); + + assert!( + cache + .lookup(user, 7, now + Duration::from_secs(30)) + .is_none() + ); + // Dropped, not just skipped + assert!(cache.lookup(user, 7, now).is_none()); + } + + #[test] + fn an_entry_from_an_older_version_is_dropped() { + let cache = ContentFilterCache::new(8, Duration::from_secs(30)); + let user = Uuid::new_v4(); + let now = Instant::now(); + cache.store(user, 7, now, filter()); + + assert!(cache.lookup(user, 8, now).is_none()); + } + + #[test] + fn capacity_evicts_the_least_recently_used_user() { + let cache = ContentFilterCache::new(2, Duration::from_secs(30)); + let (a, b, c) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()); + let now = Instant::now(); + cache.store(a, 1, now, filter()); + cache.store(b, 1, now, filter()); + cache.lookup(a, 1, now).unwrap(); + cache.store(c, 1, now, filter()); + + assert!(cache.lookup(a, 1, now).is_some()); + assert!(cache.lookup(b, 1, now).is_none()); + assert!(cache.lookup(c, 1, now).is_some()); + } +} + +#[cfg(test)] +mod db_tests { + use super::*; + use codex_db::ScanningStrategy; + use codex_db::entities::user_access_groups::MembershipSource; + use codex_db::entities::user_sharing_tags::AccessMode; + use codex_db::repositories::{ + AccessGroupRepository, AccessGroupRules, AccessGroupRulesRepository, LibraryRepository, + NameRule, SeriesRepository, SharingTagRepository, UserRepository, + }; + use codex_db::test_helpers::create_test_db; + + async fn make_user(db: &DatabaseConnection) -> Uuid { + let now = chrono::Utc::now(); + let model = codex_db::entities::users::Model { + id: Uuid::new_v4(), + username: format!("u-{}", Uuid::new_v4()), + email: format!("{}@test.com", Uuid::new_v4()), + password_hash: "hash".to_string(), + role: "reader".to_string(), + is_active: true, + email_verified: true, + permissions: serde_json::json!([]), + created_at: now, + updated_at: now, + last_login_at: None, + }; + UserRepository::create(db, &model).await.unwrap().id + } + + /// A long TTL, so only invalidation can explain a rebuild. + fn cache() -> ContentFilterCache { + ContentFilterCache::new(8, Duration::from_secs(3600)) + } + + #[tokio::test] + async fn a_new_personal_deny_applies_on_the_next_request() { + let (db, _tmp) = create_test_db().await; + let conn = db.sea_orm_connection(); + let cache = cache(); + let user = make_user(conn).await; + let lib = LibraryRepository::create(conn, "L", "/tmp/l", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(conn, lib.id, "S", None) + .await + .unwrap(); + let tag = SharingTagRepository::create(conn, "t", None).await.unwrap(); + SharingTagRepository::add_tag_to_series(conn, series.id, tag.id) + .await + .unwrap(); + + assert!( + cache + .get(conn, user) + .await + .unwrap() + .is_series_visible(series.id) + ); + + SharingTagRepository::set_user_grant(conn, user, tag.id, AccessMode::Deny) + .await + .unwrap(); + + assert!( + !cache + .get(conn, user) + .await + .unwrap() + .is_series_visible(series.id) + ); + } + + #[tokio::test] + async fn rule_and_membership_changes_apply_on_the_next_request() { + let (db, _tmp) = create_test_db().await; + let conn = db.sea_orm_connection(); + let cache = cache(); + let user = make_user(conn).await; + let lib = LibraryRepository::create(conn, "L", "/tmp/l", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(conn, lib.id, "S", None) + .await + .unwrap(); + codex_db::repositories::GenreRepository::add_genre_to_series(conn, series.id, "Adult") + .await + .unwrap(); + let group = AccessGroupRepository::create(conn, "Kids", None) + .await + .unwrap(); + AccessGroupRepository::add_member(conn, group.id, user, MembershipSource::Manual) + .await + .unwrap(); + assert!( + cache + .get(conn, user) + .await + .unwrap() + .is_series_visible(series.id) + ); + + AccessGroupRulesRepository::replace( + conn, + group.id, + &AccessGroupRules { + genres: vec![NameRule::new("Adult", AccessMode::Deny)], + ..Default::default() + }, + ) + .await + .unwrap(); + assert!( + !cache + .get(conn, user) + .await + .unwrap() + .is_series_visible(series.id) + ); + + AccessGroupRepository::remove_member(conn, group.id, user) + .await + .unwrap(); + assert!( + cache + .get(conn, user) + .await + .unwrap() + .is_series_visible(series.id) + ); + } + + #[tokio::test] + async fn a_role_change_applies_on_the_next_request() { + let (db, _tmp) = create_test_db().await; + let conn = db.sea_orm_connection(); + let cache = cache(); + let user = make_user(conn).await; + let lib = LibraryRepository::create(conn, "L", "/tmp/l", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(conn, lib.id, "S", None) + .await + .unwrap(); + let tag = SharingTagRepository::create(conn, "t", None).await.unwrap(); + SharingTagRepository::add_tag_to_series(conn, series.id, tag.id) + .await + .unwrap(); + SharingTagRepository::set_user_grant(conn, user, tag.id, AccessMode::Deny) + .await + .unwrap(); + assert!( + !cache + .get(conn, user) + .await + .unwrap() + .is_series_visible(series.id) + ); + + let mut model = UserRepository::get_by_id(conn, user) + .await + .unwrap() + .unwrap(); + model.role = "admin".to_string(); + UserRepository::update(conn, &model).await.unwrap(); + + assert!( + cache + .get(conn, user) + .await + .unwrap() + .is_series_visible(series.id) + ); + } +} diff --git a/crates/codex-services/src/lib.rs b/crates/codex-services/src/lib.rs index 1d38b0335..1948e9abe 100644 --- a/crates/codex-services/src/lib.rs +++ b/crates/codex-services/src/lib.rs @@ -12,6 +12,7 @@ pub mod metrics; pub mod cleanup_subscriber; pub mod collection_membership; pub mod content_filter; +pub mod content_filter_cache; pub mod email; pub mod entity_event_publisher; pub mod export_storage; diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 0995dcbc4..1ca58e346 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -231,6 +231,54 @@ ] } }, + "/api/v1/access-groups/{group_id}/add-all-users": { + "post": { + "tags": [ + "Access Groups" + ], + "summary": "Add every user who is not yet a member to an access group (admin only)", + "description": "Meant for a group that has just become the default, since the default only\napplies to accounts created afterwards. Existing memberships keep their\nsource; new ones are recorded as `default`.", + "operationId": "add_all_users_to_access_group", + "parameters": [ + { + "name": "group_id", + "in": "path", + "description": "Access group ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Users added", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddAllUsersResponse" + } + } + } + }, + "403": { + "description": "Forbidden - Missing permission" + }, + "404": { + "description": "Access group not found" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/access-groups/{group_id}/grants": { "post": { "tags": [ @@ -555,6 +603,112 @@ ] } }, + "/api/v1/access-groups/{group_id}/rules": { + "get": { + "tags": [ + "Access Groups" + ], + "summary": "Get every rule of an access group (admin only)", + "operationId": "get_access_group_rules", + "parameters": [ + { + "name": "group_id", + "in": "path", + "description": "Access group ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The group's rules", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessGroupRulesDto" + } + } + } + }, + "403": { + "description": "Forbidden - Missing permission" + }, + "404": { + "description": "Access group not found" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + }, + "put": { + "tags": [ + "Access Groups" + ], + "summary": "Replace every rule of an access group (admin only)", + "description": "The whole rule set is replaced atomically: anything not in the request is\nremoved. Sharing tag and library IDs must exist; tag and genre names need\nnot, so a rule can be set up before any series carries the name.", + "operationId": "replace_access_group_rules", + "parameters": [ + { + "name": "group_id", + "in": "path", + "description": "Access group ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceAccessGroupRulesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Rules replaced", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessGroupRulesDto" + } + } + } + }, + "400": { + "description": "Duplicate rule, blank name, or unknown sharing tag or library" + }, + "403": { + "description": "Forbidden - Missing permission" + }, + "404": { + "description": "Access group not found" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/admin/cleanup-orphans": { "post": { "tags": [ @@ -9691,7 +9845,7 @@ { "name": "exact", "in": "query", - "description": "Count pages exactly instead of estimating. Admin only: on PostgreSQL an\nexact count is a full scan of the largest table.", + "description": "Count pages exactly instead of estimating. On PostgreSQL an exact count\nis a full scan of the largest table.", "required": false, "schema": { "type": "boolean" @@ -9710,7 +9864,7 @@ } }, "403": { - "description": "Permission denied, or `exact=true` requested without admin access" + "description": "Requires tasks:read (admins and maintainers)" } }, "security": [ @@ -9743,7 +9897,7 @@ } }, "403": { - "description": "Permission denied" + "description": "Requires tasks:read (admins and maintainers)" } }, "security": [ @@ -9776,7 +9930,7 @@ } }, "403": { - "description": "Permission denied" + "description": "Requires tasks:read (admins and maintainers)" }, "503": { "description": "Task metrics service not available" @@ -9924,7 +10078,7 @@ } }, "403": { - "description": "Permission denied" + "description": "Requires tasks:read (admins and maintainers)" }, "503": { "description": "Task metrics service not available" @@ -19823,6 +19977,67 @@ ] } }, + "/api/v1/users/{user_id}/visibility-preview": { + "get": { + "tags": [ + "Access Groups" + ], + "summary": "Preview what a user can see (admin only)", + "description": "Counts the series the user can and cannot see and lists every library with\nwhether they can see it. With `seriesId`, also explains that series: which\ndeny rules hide it (and from which group), or which groups let it through,\nor that no allow rule matches it. Uses the same rules as enforcement, built\nfresh rather than from the cache.", + "operationId": "get_visibility_preview", + "parameters": [ + { + "name": "user_id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "seriesId", + "in": "query", + "description": "Explain whether the user can see this series", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "What the user can see", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VisibilityPreviewDto" + } + } + } + }, + "403": { + "description": "Forbidden - Missing permission" + }, + "404": { + "description": "User or series not found" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/want-to-read": { "get": { "tags": [ @@ -23536,12 +23751,24 @@ }, "components": { "schemas": { + "AccessDecision": { + "type": "string", + "description": "The outcome for one series, and the reason for it.", + "enum": [ + "unrestricted", + "open", + "allowed", + "notAllowed", + "denied" + ] + }, "AccessGroupDetailDto": { "type": "object", "description": "Access group detail (includes members, grants, and OIDC mappings)", "required": [ "id", "name", + "isDefault", "members", "grants", "oidcMappings", @@ -23576,6 +23803,11 @@ "description": "Unique access group identifier", "example": "550e8400-e29b-41d4-a716-446655440000" }, + "isDefault": { + "type": "boolean", + "description": "Whether new users join this group when their account is created", + "example": false + }, "members": { "type": "array", "items": { @@ -23609,8 +23841,10 @@ "required": [ "id", "name", + "isDefault", "memberCount", "grantCount", + "ruleCount", "createdAt", "updatedAt" ], @@ -23632,7 +23866,7 @@ "grantCount": { "type": "integer", "format": "int64", - "description": "Number of tag grants in the group", + "description": "Number of sharing tag grants in the group", "example": 3, "minimum": 0 }, @@ -23642,6 +23876,11 @@ "description": "Unique access group identifier", "example": "550e8400-e29b-41d4-a716-446655440000" }, + "isDefault": { + "type": "boolean", + "description": "Whether new users join this group when their account is created", + "example": false + }, "memberCount": { "type": "integer", "format": "int64", @@ -23654,6 +23893,13 @@ "description": "Display name of the access group", "example": "Manga Readers" }, + "ruleCount": { + "type": "integer", + "format": "int64", + "description": "Number of rules in the group, across sharing tags, tags, genres and libraries", + "example": 5, + "minimum": 0 + }, "updatedAt": { "type": "string", "format": "date-time", @@ -23757,6 +24003,61 @@ } } }, + "AccessGroupRulesDto": { + "type": "object", + "description": "Every rule of an access group\n\nWithin a group, values of the same type combine as \"any of\" or \"all of\"\n(per the match field for that type; libraries are always \"any of\"), and\ndifferent types combine with AND. Across groups, what each group allows is\ncombined with OR. Denies from every group always win. Admins and\nmaintainers are never restricted.", + "required": [ + "sharingTagMatch", + "tagMatch", + "genreMatch", + "sharingTags", + "tags", + "genres", + "libraries" + ], + "properties": { + "genreMatch": { + "$ref": "#/components/schemas/RuleMatch", + "description": "How allowed genres combine" + }, + "genres": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameRuleDto" + }, + "description": "Rules on genres, matched by name" + }, + "libraries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryRuleDto" + }, + "description": "Rules on libraries" + }, + "sharingTagMatch": { + "$ref": "#/components/schemas/RuleMatch", + "description": "How allowed sharing tags combine" + }, + "sharingTags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SharingTagRuleDto" + }, + "description": "Rules on sharing tags" + }, + "tagMatch": { + "$ref": "#/components/schemas/RuleMatch", + "description": "How allowed tags combine" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameRuleDto" + }, + "description": "Rules on regular tags, matched by name" + } + } + }, "AccessGroupSummaryDto": { "type": "object", "description": "Access group summary (for user's group list)", @@ -23848,6 +24149,22 @@ } } }, + "AddAllUsersResponse": { + "type": "object", + "description": "Result of adding every user to an access group", + "required": [ + "added" + ], + "properties": { + "added": { + "type": "integer", + "format": "int64", + "description": "Users who were not members yet and have been added", + "example": 12, + "minimum": 0 + } + } + }, "AddBookGenreRequest": { "type": "object", "description": "Request to add a single genre to a book", @@ -34921,6 +35238,71 @@ } } }, + "LibraryRuleDto": { + "type": "object", + "description": "A rule on a library", + "required": [ + "libraryId", + "name", + "accessMode" + ], + "properties": { + "accessMode": { + "$ref": "#/components/schemas/AccessMode", + "description": "Allow or deny" + }, + "libraryId": { + "type": "string", + "format": "uuid", + "description": "Library ID" + }, + "name": { + "type": "string", + "description": "Library name", + "example": "Manga" + } + } + }, + "LibraryRuleInput": { + "type": "object", + "description": "A library rule in a replace request", + "required": [ + "libraryId", + "accessMode" + ], + "properties": { + "accessMode": { + "$ref": "#/components/schemas/AccessMode", + "description": "Allow or deny" + }, + "libraryId": { + "type": "string", + "format": "uuid", + "description": "Library ID" + } + } + }, + "LibraryVisibilityDto": { + "type": "object", + "description": "A library and whether the previewed user can see it", + "required": [ + "id", + "name", + "visible" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "visible": { + "type": "boolean" + } + } + }, "LinkProperties": { "type": "object", "description": "Additional properties that can be attached to links", @@ -35161,12 +35543,34 @@ } } }, + "MatchedRuleDto": { + "type": "object", + "description": "A deny rule that matches a series", + "required": [ + "ruleType", + "value", + "source" + ], + "properties": { + "ruleType": { + "$ref": "#/components/schemas/RuleTypeLabel" + }, + "source": { + "$ref": "#/components/schemas/GrantSourceDto" + }, + "value": { + "type": "string", + "description": "The tag or genre name, or the sharing tag or library name" + } + } + }, "MembershipSource": { "type": "string", "description": "Provenance of a group membership", "enum": [ "manual", - "oidc" + "oidc", + "default" ] }, "MessageResponse": { @@ -35959,6 +36363,25 @@ } } }, + "NameRuleDto": { + "type": "object", + "description": "A rule on a regular tag or a genre\n\nMatched by name, case-insensitively, so the rule keeps applying when the\ntag or genre is removed by cleanup and created again later.", + "required": [ + "name", + "accessMode" + ], + "properties": { + "accessMode": { + "$ref": "#/components/schemas/AccessMode", + "description": "Allow or deny" + }, + "name": { + "type": "string", + "description": "Tag or genre name", + "example": "Adult" + } + } + }, "NewsEntryDto": { "type": "object", "description": "One news entry.", @@ -41495,6 +41918,71 @@ } } }, + "ReplaceAccessGroupRulesRequest": { + "type": "object", + "description": "Replace every rule of an access group\n\nOmitted lists are treated as empty and omitted match fields as \"any\".", + "properties": { + "genreMatch": { + "oneOf": [ + { + "$ref": "#/components/schemas/RuleMatch", + "description": "How allowed genres combine" + } + ], + "default": "any" + }, + "genres": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameRuleDto" + }, + "description": "Rules on genres, matched by name", + "default": [] + }, + "libraries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryRuleInput" + }, + "description": "Rules on libraries", + "default": [] + }, + "sharingTagMatch": { + "oneOf": [ + { + "$ref": "#/components/schemas/RuleMatch", + "description": "How allowed sharing tags combine" + } + ], + "default": "any" + }, + "sharingTags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SharingTagRuleInput" + }, + "description": "Rules on sharing tags", + "default": [] + }, + "tagMatch": { + "oneOf": [ + { + "$ref": "#/components/schemas/RuleMatch", + "description": "How allowed tags combine" + } + ], + "default": "any" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameRuleDto" + }, + "description": "Rules on regular tags, matched by name", + "default": [] + } + } + }, "ReplaceBookMetadataRequest": { "type": "object", "description": "PUT request for full replacement of book metadata\n\nAll metadata fields will be replaced with the values in this request.\nOmitting a field (or setting it to null) will clear that field.", @@ -42210,6 +42698,24 @@ } } }, + "RuleMatch": { + "type": "string", + "description": "How a group's allowed values of one rule type combine.", + "enum": [ + "any", + "all" + ] + }, + "RuleTypeLabel": { + "type": "string", + "description": "The kind of value a rule names.", + "enum": [ + "sharingTag", + "tag", + "genre", + "library" + ] + }, "RunNowResponse": { "type": "object", "description": "Response for `POST .../run-now`.", @@ -42472,6 +42978,47 @@ } } }, + "SeriesAccessDto": { + "type": "object", + "description": "Whether the previewed user can see one series, and why", + "required": [ + "seriesId", + "title", + "visible", + "decision", + "deniedBy", + "allowedBy" + ], + "properties": { + "allowedBy": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GrantSourceDto" + }, + "description": "Every group (or personal grant) whose allow rules let it through" + }, + "decision": { + "$ref": "#/components/schemas/AccessDecision" + }, + "deniedBy": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MatchedRuleDto" + }, + "description": "Every deny rule that matches this series" + }, + "seriesId": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "visible": { + "type": "boolean" + } + } + }, "SeriesAliasDto": { "type": "object", "description": "Title alias used by release-source plugins to match incoming releases by\ntitle (Nyaa, MangaUpdates without an external ID, etc.).", @@ -44553,6 +45100,50 @@ } } }, + "SharingTagRuleDto": { + "type": "object", + "description": "A rule on a sharing tag", + "required": [ + "sharingTagId", + "name", + "accessMode" + ], + "properties": { + "accessMode": { + "$ref": "#/components/schemas/AccessMode", + "description": "Allow or deny" + }, + "name": { + "type": "string", + "description": "Sharing tag name", + "example": "Kids" + }, + "sharingTagId": { + "type": "string", + "format": "uuid", + "description": "Sharing tag ID" + } + } + }, + "SharingTagRuleInput": { + "type": "object", + "description": "A sharing tag rule in a replace request", + "required": [ + "sharingTagId", + "accessMode" + ], + "properties": { + "accessMode": { + "$ref": "#/components/schemas/AccessMode", + "description": "Allow or deny" + }, + "sharingTagId": { + "type": "string", + "format": "uuid", + "description": "Sharing tag ID" + } + } + }, "SharingTagSummaryDto": { "type": "object", "description": "Simplified sharing tag for lists (without counts)", @@ -46465,6 +47056,14 @@ "description": "New description (set to null to remove)", "example": "Updated description" }, + "isDefault": { + "type": [ + "boolean", + "null" + ], + "description": "Make this the default group (clearing any other default), or stop it\nbeing the default. Only affects accounts created afterwards.", + "example": true + }, "name": { "type": [ "string", @@ -47543,6 +48142,33 @@ } } }, + "UserAccessGroupDto": { + "type": "object", + "description": "An access group membership as shown on a user", + "required": [ + "id", + "name", + "source" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Access group ID", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "name": { + "type": "string", + "description": "Access group name", + "example": "Kids" + }, + "source": { + "type": "string", + "description": "Membership source (manual or oidc)", + "example": "manual" + } + } + }, "UserDetailDto": { "type": "object", "description": "User detail DTO with sharing tag grants (for single user view)", @@ -47631,9 +48257,17 @@ "permissions", "isActive", "createdAt", - "updatedAt" + "updatedAt", + "accessGroups" ], "properties": { + "accessGroups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserAccessGroupDto" + }, + "description": "Access groups the user belongs to, sorted by name" + }, "createdAt": { "type": "string", "format": "date-time", @@ -48334,6 +48968,56 @@ } } }, + "VisibilityPreviewDto": { + "type": "object", + "description": "What a user can see, for an admin checking an access setup", + "required": [ + "userId", + "restricted", + "roleBypass", + "visibleSeriesCount", + "hiddenSeriesCount", + "libraries" + ], + "properties": { + "hiddenSeriesCount": { + "type": "integer", + "format": "int64", + "description": "Series hidden from the user", + "minimum": 0 + }, + "libraries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryVisibilityDto" + }, + "description": "Every library, and whether the user can see it" + }, + "restricted": { + "type": "boolean", + "description": "False for admins and maintainers, and for users no rule applies to" + }, + "roleBypass": { + "type": "boolean", + "description": "The user's role exempts them from access groups (admin or maintainer)" + }, + "series": { + "$ref": "#/components/schemas/SeriesAccessDto", + "description": "Present when a series was asked about" + }, + "userId": { + "type": "string", + "format": "uuid", + "description": "The previewed user" + }, + "visibleSeriesCount": { + "type": "integer", + "format": "int64", + "description": "Series the user can see", + "minimum": 0 + } + } + }, "WantToReadEntryDto": { "type": "object", "description": "A single entry in a user's want-to-read queue. Exactly one of `series_id` /\n`book_id` is populated, matching `item_type`.", diff --git a/docs/docs/third-party-apps.md b/docs/docs/third-party-apps.md index db9fffe1a..db5f0ae30 100644 --- a/docs/docs/third-party-apps.md +++ b/docs/docs/third-party-apps.md @@ -310,9 +310,8 @@ The Komga-compatible API has some limitations compared to the native Komga serve ### Differences from Komga - **User permissions** - Uses Codex's permission model (admin, maintainer, reader) -- **Library restrictions** - Not implemented (all libraries are shared) -- **Age restrictions** - Not implemented -- **Content restrictions** - Not implemented +- **Library and content restrictions** - Use Codex [access groups](./users/access-groups) rather than Komga's per-user library sharing and labels. A restricted reader sees exactly the same libraries, series and books through the Komga API as in the Codex web app. +- **Age restrictions** - Not implemented as a separate setting; use access group rules (for example, deny a genre) ## Security Considerations diff --git a/docs/docs/users/access-groups.md b/docs/docs/users/access-groups.md index cb4706173..b2e8726e9 100644 --- a/docs/docs/users/access-groups.md +++ b/docs/docs/users/access-groups.md @@ -4,58 +4,93 @@ sidebar_position: 7 # Access Groups -Access groups let you bundle a set of [sharing-tag](./sharing-tags) grants and assign them to multiple users at once. Instead of configuring 50 identical per-user grants, create one group, define its rules, and add users to it. +Access groups decide what content their members can see. A group holds **rules**: allow or deny a library, a genre, a tag, or a [sharing tag](./sharing-tags). You assign the group to many users at once instead of configuring each user separately. -## Overview +:::info Admins and maintainers are never restricted +Access groups and sharing-tag grants do not apply to users with the **admin** or **maintainer** role, even when they are members of a group. Both always see the whole library: maintainers curate metadata, covers and scans, which needs every series. Only **readers** are restricted, so check a restricted setup with a reader account. +::: + +## Rule Types + +| Rule on | Matches a series when | Notes | +|---------|-----------------------|-------| +| **Library** | the series is in that library | A new library is hidden from whitelisted users until a group allows it | +| **Genre** | the series has that genre | Matched by name, ignoring case | +| **Tag** | the series has that tag | Matched by name, ignoring case | +| **Sharing tag** | the series carries that sharing tag | Sharing tags are applied by hand; see [sharing tags](./sharing-tags) | + +Each rule is either **allow** or **deny**. A value can appear once per group. + +Genre and tag rules are matched by **name**, not by the genre or tag itself. Codex removes genres and tags that no series uses anymore, and the next series that carries the name creates it again. A rule on "Adult" keeps working through that, and you can create a rule for a genre that no series has yet. -Access groups sit between sharing tags and users: +## How Rules Combine + +1. **Allow rules inside one group narrow each other.** + - Values of the **same type** combine as "any of" by default: allowing the genres Kids and Comedy shows series with either genre. For genres, tags and sharing tags you can switch a group to "all of", so only series with **every** allowed value match. Libraries are always "any of", since a series is in exactly one library. + - **Different types** must all match: "allow library Manga" plus "allow genre Kids" shows Kids-genre series in the Manga library, not all of Manga plus every Kids series. +2. **Groups add up.** Each group grants its own scope, and a member of several groups sees everything any of their groups allows. A user's personal sharing-tag grants count as one more group. +3. **Deny always wins, everywhere.** Every deny rule, from every group the user is in and from their personal grants, is collected into one list and hidden at the end. A deny is not limited to the group it is written in, so no other group can grant that content back. +4. **No allow rules at all** (no groups, or only groups with deny rules): the user sees everything that is not denied. ``` -Sharing Tags Access Groups Users - +---------+ +---------------+ +-------+ - | manga |----->| Manga Readers |---->| alice | - | 18+ |----->| allow manga |---->| bob | - +---------+ | deny 18+ | +-------+ - +---------------+ - Per-user overrides - still apply on top +visible = (what group A allows) + (what group B allows) + ... - (every deny from every group) ``` -- A group has a name, description, and a set of tag grants (allow/deny). -- Users can belong to multiple groups. -- Per-user grants in the existing `Sharing Tag Grants` panel continue to work as overrides on top of group rules. -- Deny always wins across all sources (groups + per-user). +:::warning Whitelist mode +As soon as any allow rule applies to a user, they only see what an allow rule matches. Series that match no allow rule are hidden, including series with no genres, tags or sharing tags at all. +::: + +### Examples -## How Grants Merge +**Two groups, one deny.** A user is in: -When a user belongs to one or more groups, their **effective grants** are the union of: +- *Manga*: allow library Manga +- *Comics, no adult*: allow library Comics, deny genre Adult -1. All grants from all their groups -2. Their per-user grants (from Settings > Users > Edit > Sharing Tag Grants) +They see all of Manga and all of Comics, **minus anything in the Adult genre in either library**. The Adult deny from the second group also applies to the manga the first group grants. -The same deny-wins rule from [sharing tags](./sharing-tags) applies to the merged set: +**One group, two types.** *Kids manga*: allow library Manga, allow genre Kids. Members see Kids-genre series in Manga only. -| Scenario | Result | -|----------|--------| -| Group allows "manga", no user override | User sees manga-tagged content (whitelist mode) | -| Group allows "manga", user denies "18+" | User sees manga-tagged content, but not 18+-tagged content | -| Two groups: one allows "manga", other denies "manga" | Deny wins; manga content hidden | -| Group allows "manga", user also allows "manga" | Same as single allow (deduplicated) | -| No groups, no user grants | User sees all content (default open) | +**Deny-only group.** *No adult*: deny genre Adult. Members see the whole library except Adult-genre series. Untagged content stays visible, because a deny-only group never starts a whitelist. -:::warning Whitelist Mode -Any `allow` grant from any source (group or per-user) activates whitelist mode for that user. In whitelist mode, **untagged content is hidden**. This is the same behavior as per-user allow grants. See the [sharing tags docs](./sharing-tags#the-access-model) for details. -::: +### Allow is safer than deny for metadata rules + +Genres and tags come from your files' metadata and from metadata plugins. A **deny** on a genre hides only series that actually carry it: a series whose metadata is missing the genre stays visible. An **allow** on a genre does the opposite: a series missing the genre stays hidden. For a kids account, "allow library Kids" or "allow genre Kids" is the safer setup, with deny rules as a second layer. + +## The Default Group + +You can mark **one** group as the default. Every account created afterwards joins it automatically: accounts created by an admin, by self-registration, and on a user's first OIDC login. + +- It is an ordinary membership (shown with the source `default`). You can remove a user from the default group, and they stay out. +- Making a group the default does **not** add existing users. Use **Add all existing users** on the group page to bring them in. Existing memberships are left as they are. +- Marking another group as default moves the default to it. + +A default group with an allow rule (for example "allow library Kids") gives you closed-by-default accounts: new users see only that until you add them to other groups. + +## Membership Sources + +Each membership records how the user got into the group: + +| Source | Added by | Removed by | +|--------|----------|------------| +| `manual` | an admin | an admin | +| `default` | account creation, or **Add all existing users** | an admin | +| `oidc` | an [OIDC](./oidc) group mapping on login | the next login after the user leaves the IdP group, or an admin | + +Whoever adds a user to a group first owns that membership. Adding the same user again from another source changes nothing: an admin adding someone the IdP already placed does not stop the IdP from removing them later, and an IdP group mapping to a group an admin assigned does not let the IdP remove that assignment. ## Managing Access Groups ### Via Web Interface 1. Go to **Settings** > **Access Groups** -2. Click **Create Group** -3. Enter a name and optional description -4. After creation, click the group name to open its detail page -5. Add **tag grants**, **members**, and optionally **OIDC mappings** +2. Click **Create Group** and enter a name and optional description +3. Open the group to configure it: + - **Default group for new users** switch and **Add all existing users** + - **Rules**: add libraries, genres, tags and sharing tags as allow or deny, and choose "any" or "all" per type + - **Members** and **OIDC mappings** + +The users list in **Settings** > **Users** shows each user's groups. ### Via API @@ -64,209 +99,134 @@ Any `allow` grant from any source (group or per-user) activates whitelist mode f curl -X POST http://localhost:8080/api/v1/access-groups \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ - -d '{ - "name": "Manga Readers", - "description": "Access to all manga content" - }' + -d '{"name": "Kids", "description": "Kids accounts"}' ``` -**List groups:** +**Replace a group's rules** (the whole set is replaced; omitted lists are emptied and omitted match fields are `any`): ```bash -curl http://localhost:8080/api/v1/access-groups \ - -H "Authorization: Bearer $ADMIN_TOKEN" -``` - -**Get group details (members, grants, OIDC mappings):** -```bash -curl http://localhost:8080/api/v1/access-groups/{id} \ - -H "Authorization: Bearer $ADMIN_TOKEN" -``` - -**Update a group:** -```bash -curl -X PATCH http://localhost:8080/api/v1/access-groups/{id} \ +curl -X PUT http://localhost:8080/api/v1/access-groups/{id}/rules \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ - -d '{"description": "Updated description"}' + -d '{ + "genreMatch": "any", + "libraries": [{"libraryId": "kids-library-uuid", "accessMode": "allow"}], + "genres": [{"name": "Adult", "accessMode": "deny"}], + "tags": [{"name": "Gore", "accessMode": "deny"}], + "sharingTags": [] + }' ``` -**Delete a group:** +**Read a group's rules:** ```bash -curl -X DELETE http://localhost:8080/api/v1/access-groups/{id} \ +curl http://localhost:8080/api/v1/access-groups/{id}/rules \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` -:::warning -Deleting a group removes all its memberships and grants. Users will lose the group's grants on their next request. -::: - -## Managing Group Grants - -Grants define what content the group's members can see. - -**Add a grant:** +**Make a group the default, or stop it being the default:** ```bash -curl -X POST http://localhost:8080/api/v1/access-groups/{id}/grants \ +curl -X PATCH http://localhost:8080/api/v1/access-groups/{id} \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ - -d '{ - "sharingTagId": "tag-uuid", - "accessMode": "allow" - }' + -d '{"isDefault": true}' ``` -**Remove a grant:** +**Add every user who is not a member yet:** ```bash -curl -X DELETE http://localhost:8080/api/v1/access-groups/{id}/grants/{tag_id} \ +curl -X POST http://localhost:8080/api/v1/access-groups/{id}/add-all-users \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` -## Managing Group Members - -**Add members:** +**Add or remove members:** ```bash curl -X POST http://localhost:8080/api/v1/access-groups/{id}/members \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ - -d '{ - "userIds": ["user-uuid-1", "user-uuid-2"] - }' -``` + -d '{"userIds": ["user-uuid-1", "user-uuid-2"]}' -**Remove a member:** -```bash curl -X DELETE http://localhost:8080/api/v1/access-groups/{id}/members/{user_id} \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` -**List a user's groups:** +**List groups, get one, update, delete:** ```bash -curl http://localhost:8080/api/v1/users/{user_id}/access-groups \ - -H "Authorization: Bearer $ADMIN_TOKEN" +curl http://localhost:8080/api/v1/access-groups -H "Authorization: Bearer $ADMIN_TOKEN" +curl http://localhost:8080/api/v1/access-groups/{id} -H "Authorization: Bearer $ADMIN_TOKEN" +curl -X PATCH http://localhost:8080/api/v1/access-groups/{id} \ + -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ + -d '{"description": "Updated description"}' +curl -X DELETE http://localhost:8080/api/v1/access-groups/{id} -H "Authorization: Bearer $ADMIN_TOKEN" ``` +The older `POST /access-groups/{id}/grants` and `DELETE /access-groups/{id}/grants/{tag_id}` endpoints still add and remove a single sharing-tag rule. + +:::warning +Deleting a group removes its memberships and rules. Members lose what the group granted on their next request. +::: + ## OIDC Auto-Assignment -If you use [OIDC authentication](./oidc), you can map IdP group names to access groups. When a user logs in via OIDC, their access group memberships are automatically reconciled: +If you use [OIDC authentication](./oidc), you can map IdP group names to access groups. On every OIDC login, the user's OIDC-sourced memberships are reconciled with their IdP groups: -- If the user's IdP groups include a mapped group name, they are added to the corresponding access group (with `source=oidc`). -- If a previously mapped group is no longer in their IdP claims, the OIDC-sourced membership is removed. -- Manually assigned memberships are never touched by the OIDC sync. +- A mapped IdP group the user is in adds them to the access group (source `oidc`), unless they are already a member. +- An `oidc` membership whose IdP group is no longer in the user's claims is removed. +- `manual` and `default` memberships are never touched by the sync. +- If the sync fails, the login fails. Codex does not let a user in with a partial or missing set of groups. -**Add an OIDC mapping:** +**Add or remove an OIDC mapping:** ```bash curl -X POST http://localhost:8080/api/v1/access-groups/{id}/oidc-mappings \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ - -d '{ - "oidcGroupName": "library-staff" - }' -``` + -d '{"oidcGroupName": "library-staff"}' -**Remove an OIDC mapping:** -```bash curl -X DELETE http://localhost:8080/api/v1/access-groups/{id}/oidc-mappings/{mapping_id} \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` :::note Case Sensitivity -OIDC group names are matched exactly as stored. If your IdP sends `Library-Staff` but the mapping says `library-staff`, it will not match. Configure the mapping to match your IdP's exact casing. +OIDC group names are matched exactly as stored. If your IdP sends `Library-Staff` but the mapping says `library-staff`, it will not match. ::: -## Debugging Effective Grants +## Checking What a User Sees -The **effective grants** endpoint shows exactly which grants apply to a user and where each one comes from (per-user override or group name). This is the primary tool for answering "why can't this user see series X?" +Open **Settings** > **Users**, edit a user, and look at **What this user sees**: -**Via Web Interface:** -1. Go to **Settings** > **Users** -2. Edit a user -3. Scroll to the **Effective Grants** panel +- how many series they can and cannot see, and which libraries are visible to them; +- **Check a series**: pick any series to see whether the user can see it and why: the deny rule that hides it and the group it comes from, the group whose allow rules let it through, or that none of their allow rules matches it. + +It uses the same rules Codex enforces, so it cannot disagree with what the user actually gets. Admins and maintainers are reported as never restricted. **Via API:** ```bash -curl http://localhost:8080/api/v1/users/{user_id}/effective-grants \ +curl "http://localhost:8080/api/v1/users/{user_id}/visibility-preview?seriesId={series_id}" \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` -Response: -```json -{ - "userId": "user-uuid", - "grants": [ - { - "sharingTagId": "tag-uuid", - "sharingTagName": "manga", - "accessMode": "allow", - "sources": [ - { - "kind": "group", - "groupId": "group-uuid", - "groupName": "Manga Readers" - } - ] - }, - { - "sharingTagId": "tag-uuid-2", - "sharingTagName": "18+", - "accessMode": "deny", - "sources": [ - { - "kind": "user", - "groupId": null, - "groupName": null - } - ] - } - ] -} -``` - -Each grant shows `sources` with `kind: "user"` for per-user overrides or `kind: "group"` with the group name for group-inherited grants. A single tag+mode combination can have multiple sources if both a user override and a group grant contribute the same rule. - -## Example: Family + Organization Setup +`GET /api/v1/users/{user_id}/effective-grants` still lists a user's sharing-tag rules and where each comes from. -Combine access groups with per-user overrides for a multi-layered access model: +## Where Restrictions Apply -**Step 1: Create tags and groups** -```bash -# Tags -curl -X POST .../admin/sharing-tags -d '{"name": "manga"}' -curl -X POST .../admin/sharing-tags -d '{"name": "comics"}' -curl -X POST .../admin/sharing-tags -d '{"name": "18+"}' - -# Groups -curl -X POST .../access-groups -d '{"name": "Manga Readers"}' -curl -X POST .../access-groups -d '{"name": "Comics Readers"}' -``` +A restricted reader gets the same view everywhere: the web app, the native API, OPDS 1.2 and 2.0 feeds, the [Komga-compatible API](../third-party-apps), KOReader sync, and live updates (new or changed series and books are not announced for content they cannot see). Hidden content answers exactly like content that does not exist: it is missing from lists, search, genre and tag lists and library counts, and opening it by id returns "not found". -**Step 2: Configure group grants** -```bash -# Manga Readers: allow manga -curl -X POST .../access-groups/{manga-group}/grants \ - -d '{"sharingTagId": "manga-tag-id", "accessMode": "allow"}' +Server-wide statistics (**Settings** > **Metrics**, and the metrics screen in Codex Reader) are only available to admins and maintainers, since they describe the whole server. -# Comics Readers: allow comics -curl -X POST .../access-groups/{comics-group}/grants \ - -d '{"sharingTagId": "comics-tag-id", "accessMode": "allow"}' -``` +Restriction changes take effect on the next request. When Codex runs as several server processes, the others pick them up within 30 seconds. New series from a scan are covered by deny rules within 30 seconds of the scan, and by allow rules immediately (a new series is hidden until a rule allows it). -**Step 3: Assign users** -```bash -# Alice gets manga access via group -curl -X POST .../access-groups/{manga-group}/members \ - -d '{"userIds": ["alice-id"]}' +## Example: A Kids Account -# But deny 18+ content specifically for Alice (per-user override) -curl -X PUT .../users/alice-id/sharing-tags \ - -d '{"sharingTagId": "18+-tag-id", "accessMode": "deny"}' -``` +1. Create a group **Kids** with these rules: + - allow library *Kids* + - deny genre *Adult* +2. Mark **Kids** as the default group, so every new account starts there. +3. For adult family members, remove them from **Kids** and add them to a group such as **Everyone** with no allow rules (or no group at all, which sees everything). +4. Check a kid's account under **What this user sees** before handing it over. -Result: Alice sees manga-tagged content, but never 18+-tagged content (deny wins). +A kid's account sees the Kids library and nothing else. If a kid is later added to another group by mistake, the *Adult* deny still applies to everything that group grants. ## Best Practices -1. **Use groups for organizational roles** rather than duplicating per-user grants -2. **Keep per-user grants for exceptions** (e.g., one user in a "Manga Readers" group who should not see 18+ content) -3. **Name groups by intent** (e.g., "Kids", "Staff", "Manga Readers") rather than by technical configuration -4. **Use the effective grants endpoint** to verify access before and after changes -5. **Map OIDC groups** when your IdP already has organizational groups, so membership stays in sync automatically +1. **Prefer allow rules for restricted accounts**, and use deny rules as a safety net: metadata is never perfect, and allow fails closed. +2. **Put hard limits in deny rules**: they hold whatever other groups a user joins. +3. **Use a default group** so new accounts never start unrestricted by accident. +4. **Name groups by intent** ("Kids", "Staff", "Manga Readers"). +5. **Map OIDC groups** when your IdP already has organizational groups. diff --git a/docs/docs/users/permissions.md b/docs/docs/users/permissions.md index 699d7e0dd..b2594b837 100644 --- a/docs/docs/users/permissions.md +++ b/docs/docs/users/permissions.md @@ -47,6 +47,7 @@ The default role for new users. Readers can: For users who manage content but not system settings. Maintainers can do everything Readers can, plus: - Create and modify libraries (but not delete them) +- See all content: [access groups](./access-groups) and [sharing tags](./sharing-tags) only restrict readers - Full series management (create, edit, delete) - Full book management (create, edit, delete) - Create, edit, and delete collections and read lists @@ -69,6 +70,7 @@ Full system access for server administrators. Admins can do everything Maintaine - Manage metadata plugins - Access system administration features - Manage sharing tags and content restrictions +- See all content: [access groups](./access-groups) and [sharing tags](./sharing-tags) never restrict an admin **Additional Permissions (6 more, 29 total):** - `libraries-delete` @@ -156,7 +158,7 @@ Shared groupings of series (collections) and books (read lists). See [Collection | Permission | Description | |------------|-------------| -| `tasks-read` | View background tasks and queue status | +| `tasks-read` | View background tasks, queue status and server metrics | | `tasks-write` | Cancel tasks, trigger operations | ### Plugin Permissions @@ -169,7 +171,7 @@ Shared groupings of series (collections) and books (read lists). See [Collection | Permission | Description | |------------|-------------| -| `system-health` | View health status and metrics | +| `system-health` | View health status (server metrics need `tasks-read`) | | `system-admin` | Full administrative access, server settings | ## Effective Permissions @@ -195,7 +197,7 @@ Custom permissions allow extending a user's access beyond their role. For exampl This user would have all Reader permissions plus `tasks-read`. :::tip -Custom permissions extend roles - they never restrict. To limit access, use [Sharing Tags](./sharing-tags) for content-level restrictions. +Custom permissions extend roles - they never restrict. To limit what content a user sees, use [access groups](./access-groups) and [sharing tags](./sharing-tags). ::: ## API Token Permissions diff --git a/docs/docs/users/sharing-tags.md b/docs/docs/users/sharing-tags.md index b8a5f6006..673bb972d 100644 --- a/docs/docs/users/sharing-tags.md +++ b/docs/docs/users/sharing-tags.md @@ -6,6 +6,12 @@ sidebar_position: 6 Sharing tags provide content-level access control, allowing admins to restrict which series specific users can see. This is useful for family sharing scenarios where you want to limit access to age-appropriate content. +Sharing tags are applied to series by hand. To restrict by library, genre or regular tag without tagging every series, use rules on an [access group](./access-groups). + +:::info Admins and maintainers are never restricted +Sharing-tag grants and access groups do not apply to users with the **admin** or **maintainer** role. +::: + ## Overview Sharing tags work independently from [permissions](./permissions). While permissions control what actions users can perform (read, write, delete), sharing tags control what content users can see. diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 709f71572..499b3dab7 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -217,6 +217,8 @@ mod m20260826_000112_create_user_series_reader_settings; mod m20260828_000113_relax_renumber_series_task_dedup; mod m20260927_000114_reading_history_survives_book_delete; mod m20261018_000115_create_news; +mod m20261025_000116_access_group_default; +mod m20261025_000117_access_group_rules; pub struct Migrator; @@ -421,6 +423,10 @@ impl MigratorTrait for Migrator { Box::new(m20260927_000114_reading_history_survives_book_delete::Migration), // News for admins, imported from the Codex release feeds. Box::new(m20261018_000115_create_news::Migration), + // At most one default access group, which new users join. + Box::new(m20261025_000116_access_group_default::Migration), + // Access group rules on regular tags, genres and libraries. + Box::new(m20261025_000117_access_group_rules::Migration), ] } } diff --git a/migration/src/m20261025_000116_access_group_default.rs b/migration/src/m20261025_000116_access_group_default.rs new file mode 100644 index 000000000..a0222f6a1 --- /dev/null +++ b/migration/src/m20261025_000116_access_group_default.rs @@ -0,0 +1,83 @@ +//! Default access group. +//! +//! At most one access group may be the default. New users are added to it +//! when their account is created (membership source `default`, which fits the +//! existing `varchar(10)` source column). The membership is an ordinary row an +//! admin can remove later, not a rule that applies to everyone. +//! +//! "At most one" is a partial unique index over the `true` rows, which both +//! SQLite and PostgreSQL support. SeaQuery's index builder has no partial +//! form, so it is raw SQL; the statement is identical on both backends. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const SINGLE_DEFAULT_INDEX: &str = "idx_access_groups_single_default"; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(AccessGroups::Table) + .add_column( + ColumnDef::new(AccessGroups::IsDefault) + .boolean() + .not_null() + .default(false), + ) + .to_owned(), + ) + .await?; + + manager + .get_connection() + .execute_unprepared(&format!( + "CREATE UNIQUE INDEX {SINGLE_DEFAULT_INDEX} \ + ON access_groups (is_default) WHERE is_default" + )) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name(SINGLE_DEFAULT_INDEX) + .table(AccessGroups::Table) + .to_owned(), + ) + .await?; + + // Default memberships stay, as manual ones: the user is still in the + // group, only the reason is gone. + manager + .get_connection() + .execute_unprepared( + "UPDATE user_access_groups SET source = 'manual' WHERE source = 'default'", + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(AccessGroups::Table) + .drop_column(AccessGroups::IsDefault) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum AccessGroups { + Table, + IsDefault, +} diff --git a/migration/src/m20261025_000117_access_group_rules.rs b/migration/src/m20261025_000117_access_group_rules.rs new file mode 100644 index 000000000..d44873589 --- /dev/null +++ b/migration/src/m20261025_000117_access_group_rules.rs @@ -0,0 +1,259 @@ +//! Access group rules on regular tags, genres and libraries. +//! +//! Until now an access group could only allow or deny sharing tags. These +//! tables let it allow or deny regular tags, genres and libraries as well, +//! each rule `allow` or `deny`, one rule per target per group. +//! +//! Tag and genre rules store the normalized name, not a foreign key. Unused +//! tags and genres are deleted by cleanup, and the next series to carry the +//! name recreates it under a new id. A foreign key would cascade the rule away +//! with the old row and silently drop a deny. Matching on the normalized name +//! survives that. The display name is kept alongside for the UI. +//! +//! Library rules keep a foreign key: libraries are only ever deleted by an +//! admin, and their series go with them. +//! +//! The three match columns on `access_groups` choose, per type, whether a +//! group's allowed values combine as "any of" (the default, and what sharing +//! tags have always done) or "all of". Libraries have no switch: a series is +//! in exactly one library, so "all of" two libraries could never match. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for column in [ + AccessGroups::SharingTagMatch, + AccessGroups::TagMatch, + AccessGroups::GenreMatch, + ] { + manager + .alter_table( + Table::alter() + .table(AccessGroups::Table) + .add_column( + ColumnDef::new(column) + .string_len(10) + .not_null() + .default("any"), + ) + .to_owned(), + ) + .await?; + } + + create_name_rule_table( + manager, + AccessGroupTagRules::Table, + "access_group_tag_rules", + ) + .await?; + create_name_rule_table( + manager, + AccessGroupGenreRules::Table, + "access_group_genre_rules", + ) + .await?; + + manager + .create_table( + Table::create() + .table(AccessGroupLibraryRules::Table) + .if_not_exists() + .col( + ColumnDef::new(RuleColumns::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(RuleColumns::AccessGroupId).uuid().not_null()) + .col( + ColumnDef::new(AccessGroupLibraryRules::LibraryId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(RuleColumns::AccessMode) + .string_len(10) + .not_null(), + ) + .col( + ColumnDef::new(RuleColumns::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .name("fk_access_group_library_rules_group_id") + .from(AccessGroupLibraryRules::Table, RuleColumns::AccessGroupId) + .to(AccessGroups::Table, AccessGroups::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .foreign_key( + ForeignKey::create() + .name("fk_access_group_library_rules_library_id") + .from( + AccessGroupLibraryRules::Table, + AccessGroupLibraryRules::LibraryId, + ) + .to(Libraries::Table, Libraries::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_access_group_library_rules_group_library") + .table(AccessGroupLibraryRules::Table) + .col(RuleColumns::AccessGroupId) + .col(AccessGroupLibraryRules::LibraryId) + .unique() + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for table in [ + AccessGroupLibraryRules::Table.into_iden(), + AccessGroupGenreRules::Table.into_iden(), + AccessGroupTagRules::Table.into_iden(), + ] { + manager + .drop_table(Table::drop().table(table).if_exists().to_owned()) + .await?; + } + + for column in [ + AccessGroups::GenreMatch, + AccessGroups::TagMatch, + AccessGroups::SharingTagMatch, + ] { + manager + .alter_table( + Table::alter() + .table(AccessGroups::Table) + .drop_column(column) + .to_owned(), + ) + .await?; + } + + Ok(()) + } +} + +/// Create a tag-style rule table keyed by normalized name. +async fn create_name_rule_table( + manager: &SchemaManager<'_>, + table: T, + name: &str, +) -> Result<(), DbErr> +where + T: IntoIden + Copy + 'static, +{ + manager + .create_table( + Table::create() + .table(table) + .if_not_exists() + .col( + ColumnDef::new(RuleColumns::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(RuleColumns::AccessGroupId).uuid().not_null()) + .col(ColumnDef::new(RuleColumns::Name).string().not_null()) + .col( + ColumnDef::new(RuleColumns::NormalizedName) + .string() + .not_null(), + ) + .col( + ColumnDef::new(RuleColumns::AccessMode) + .string_len(10) + .not_null(), + ) + .col( + ColumnDef::new(RuleColumns::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .name(format!("fk_{name}_group_id")) + .from(table, RuleColumns::AccessGroupId) + .to(AccessGroups::Table, AccessGroups::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name(format!("idx_{name}_group_name")) + .table(table) + .col(RuleColumns::AccessGroupId) + .col(RuleColumns::NormalizedName) + .unique() + .to_owned(), + ) + .await +} + +#[derive(DeriveIden)] +enum AccessGroups { + Table, + Id, + SharingTagMatch, + TagMatch, + GenreMatch, +} + +#[derive(DeriveIden, Clone, Copy)] +enum AccessGroupTagRules { + Table, +} + +#[derive(DeriveIden, Clone, Copy)] +enum AccessGroupGenreRules { + Table, +} + +#[derive(DeriveIden)] +enum AccessGroupLibraryRules { + Table, + LibraryId, +} + +/// Columns shared by every rule table. +#[derive(DeriveIden)] +enum RuleColumns { + Id, + AccessGroupId, + Name, + NormalizedName, + AccessMode, + CreatedAt, +} + +#[derive(DeriveIden)] +enum Libraries { + Table, + Id, +} diff --git a/tests/api/access_groups.rs b/tests/api/access_groups.rs index 6d8376d7a..ad07a7e04 100644 --- a/tests/api/access_groups.rs +++ b/tests/api/access_groups.rs @@ -173,6 +173,7 @@ async fn test_update_access_group() { let update = UpdateAccessGroupRequest { name: Some("New Name".to_string()), description: Some(Some("Now with description".to_string())), + is_default: None, }; let request = patch_json_request_with_auth( &format!("/api/v1/access-groups/{}", group.id), @@ -518,6 +519,56 @@ async fn test_get_user_access_groups() { assert!(names.contains(&"Group B")); } +#[tokio::test] +async fn test_list_users_includes_access_groups() { + use codex::db::entities::user_access_groups::MembershipSource; + use codex::db::repositories::AccessGroupRepository; + + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_admin_and_token(&db, &state).await; + let (alice_id, _) = create_reader_and_token(&db, &state, "alice").await; + let (bob_id, _) = create_reader_and_token(&db, &state, "bob").await; + + let kids = AccessGroupRepository::create(&db, "Kids", None) + .await + .unwrap(); + let manga = AccessGroupRepository::create(&db, "Manga", None) + .await + .unwrap(); + AccessGroupRepository::add_member(&db, manga.id, alice_id, MembershipSource::Oidc) + .await + .unwrap(); + AccessGroupRepository::add_member(&db, kids.id, alice_id, MembershipSource::Manual) + .await + .unwrap(); + + let app = create_test_router(state).await; + let request = get_request_with_auth("/api/v1/users", &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + let users = response.unwrap()["data"].as_array().unwrap().clone(); + + let groups_of = |id: uuid::Uuid| { + users.iter().find(|u| u["id"] == id.to_string()).unwrap()["accessGroups"] + .as_array() + .unwrap() + .clone() + }; + + // Sorted by name, with the membership source + let alice_groups = groups_of(alice_id); + assert_eq!(alice_groups.len(), 2); + assert_eq!(alice_groups[0]["name"], "Kids"); + assert_eq!(alice_groups[0]["source"], "manual"); + assert_eq!(alice_groups[0]["id"], kids.id.to_string()); + assert_eq!(alice_groups[1]["name"], "Manga"); + assert_eq!(alice_groups[1]["source"], "oidc"); + + assert!(groups_of(bob_id).is_empty()); +} + // ============================================================================ // Effective Grants Tests // ============================================================================ @@ -803,3 +854,363 @@ async fn test_list_access_groups_with_counts() { assert_eq!(groups[0].member_count, 1); assert_eq!(groups[0].grant_count, 1); } + +// ============================================================================ +// Default Group Tests +// ============================================================================ + +async fn create_group( + state: &std::sync::Arc, + token: &str, + name: &str, +) -> AccessGroupDto { + let app = create_test_router(state.clone()).await; + let body = CreateAccessGroupRequest { + name: name.to_string(), + description: None, + }; + let request = post_json_request_with_auth("/api/v1/access-groups", &body, token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::CREATED); + response.unwrap() +} + +async fn set_default( + state: &std::sync::Arc, + token: &str, + group_id: uuid::Uuid, + is_default: bool, +) -> AccessGroupDto { + let app = create_test_router(state.clone()).await; + let request = patch_json_request_with_auth( + &format!("/api/v1/access-groups/{}", group_id), + &serde_json::json!({ "isDefault": is_default }), + token, + ); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + response.unwrap() +} + +async fn group_members( + state: &std::sync::Arc, + token: &str, + group_id: uuid::Uuid, +) -> AccessGroupDetailDto { + let app = create_test_router(state.clone()).await; + let request = get_request_with_auth(&format!("/api/v1/access-groups/{}", group_id), token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + response.unwrap() +} + +#[tokio::test] +async fn test_mark_group_default_moves_the_default() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_admin_and_token(&db, &state).await; + let kids = create_group(&state, &token, "Kids").await; + let teens = create_group(&state, &token, "Teens").await; + assert!(!kids.is_default); + + assert!(set_default(&state, &token, kids.id, true).await.is_default); + assert!(set_default(&state, &token, teens.id, true).await.is_default); + + assert!(!group_members(&state, &token, kids.id).await.is_default); + assert!(group_members(&state, &token, teens.id).await.is_default); + + assert!( + !set_default(&state, &token, teens.id, false) + .await + .is_default + ); +} + +#[tokio::test] +async fn test_admin_created_user_joins_default_group() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_admin_and_token(&db, &state).await; + let kids = create_group(&state, &token, "Kids").await; + set_default(&state, &token, kids.id, true).await; + + let app = create_test_router(state.clone()).await; + let request = post_json_request_with_auth( + "/api/v1/users", + &serde_json::json!({ + "username": "kid", + "email": "kid@example.com", + "password": "securepassword123" + }), + &token, + ); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + let created = response.unwrap(); + assert_eq!(created["accessGroups"][0]["name"], "Kids"); + assert_eq!(created["accessGroups"][0]["source"], "default"); + + let detail = group_members(&state, &token, kids.id).await; + assert_eq!(detail.members.len(), 1); + assert_eq!(detail.members[0].username, "kid"); +} + +#[tokio::test] +async fn test_registered_user_joins_default_group() { + use codex::db::repositories::SettingsRepository; + + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (admin_id, token) = create_admin_and_token(&db, &state).await; + let kids = create_group(&state, &token, "Kids").await; + set_default(&state, &token, kids.id, true).await; + SettingsRepository::set( + &db, + "auth.registration_enabled", + "true".to_string(), + admin_id, + None, + None, + ) + .await + .unwrap(); + + let app = create_test_router(state.clone()).await; + let request = post_json_request( + "/api/v1/auth/register", + &serde_json::json!({ + "username": "kid", + "email": "kid@example.com", + "password": "securepassword123" + }), + ); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + assert!(status.is_success(), "registration failed: {status}"); + + let detail = group_members(&state, &token, kids.id).await; + assert_eq!(detail.members.len(), 1); + assert_eq!(detail.members[0].username, "kid"); + assert_eq!(detail.members[0].source, "default"); +} + +#[tokio::test] +async fn test_add_all_users_to_group() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_admin_and_token(&db, &state).await; + create_reader_and_token(&db, &state, "alice").await; + create_reader_and_token(&db, &state, "bob").await; + let kids = create_group(&state, &token, "Kids").await; + + let app = create_test_router(state.clone()).await; + let request = post_request_with_auth( + &format!("/api/v1/access-groups/{}/add-all-users", kids.id), + &token, + ); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + // The admin, alice and bob + assert_eq!(response.unwrap()["added"], 3); + + // Running it again adds nobody + let app = create_test_router(state.clone()).await; + let request = post_request_with_auth( + &format!("/api/v1/access-groups/{}/add-all-users", kids.id), + &token, + ); + let (_, response): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(response.unwrap()["added"], 0); + + assert_eq!( + group_members(&state, &token, kids.id).await.members.len(), + 3 + ); +} + +#[tokio::test] +async fn test_add_all_users_requires_admin() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_admin_and_token(&db, &state).await; + let (_, reader_token) = create_reader_and_token(&db, &state, "alice").await; + let kids = create_group(&state, &token, "Kids").await; + + let app = create_test_router(state).await; + let request = post_request_with_auth( + &format!("/api/v1/access-groups/{}/add-all-users", kids.id), + &reader_token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::FORBIDDEN); +} + +// ============================================================================ +// Rules Tests +// ============================================================================ + +async fn put_rules( + state: &std::sync::Arc, + token: &str, + group_id: uuid::Uuid, + body: serde_json::Value, +) -> (StatusCode, Option) { + let app = create_test_router(state.clone()).await; + let request = put_json_request_with_auth( + &format!("/api/v1/access-groups/{}/rules", group_id), + &body, + token, + ); + make_json_request(app, request).await +} + +#[tokio::test] +async fn test_replace_and_get_rules() { + use codex::db::ScanningStrategy; + use codex::db::repositories::LibraryRepository; + + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_admin_and_token(&db, &state).await; + let group = create_group(&state, &token, "Kids").await; + let library = LibraryRepository::create(&db, "Kids", "/tmp/kids", ScanningStrategy::Default) + .await + .unwrap(); + let tag = SharingTagRepository::create(&db, "family", None) + .await + .unwrap(); + + let (status, response) = put_rules( + &state, + &token, + group.id, + serde_json::json!({ + "sharingTagMatch": "any", + "tagMatch": "any", + "genreMatch": "all", + "sharingTags": [{ "sharingTagId": tag.id, "accessMode": "allow" }], + "tags": [{ "name": "Gore", "accessMode": "deny" }], + "genres": [ + { "name": "Adult", "accessMode": "deny" }, + { "name": "Kids", "accessMode": "allow" } + ], + "libraries": [{ "libraryId": library.id, "accessMode": "allow" }] + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{response:?}"); + + let app = create_test_router(state.clone()).await; + let request = + get_request_with_auth(&format!("/api/v1/access-groups/{}/rules", group.id), &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + let rules = response.unwrap(); + assert_eq!(rules["genreMatch"], "all"); + assert_eq!(rules["sharingTags"][0]["name"], "family"); + assert_eq!(rules["tags"][0]["name"], "Gore"); + assert_eq!(rules["genres"].as_array().unwrap().len(), 2); + assert_eq!(rules["libraries"][0]["libraryId"], library.id.to_string()); + assert_eq!(rules["libraries"][0]["name"], "Kids"); + assert_eq!(rules["libraries"][0]["accessMode"], "allow"); + + // The group list counts every rule, not only sharing tags + let app = create_test_router(state.clone()).await; + let request = get_request_with_auth("/api/v1/access-groups", &token); + let (_, response): (StatusCode, Option>) = + make_json_request(app, request).await; + let listed = response.unwrap(); + assert_eq!(listed[0].rule_count, 5); + assert_eq!(listed[0].grant_count, 1); +} + +#[tokio::test] +async fn test_replace_rules_rejects_duplicates() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_admin_and_token(&db, &state).await; + let group = create_group(&state, &token, "Kids").await; + + let (status, _) = put_rules( + &state, + &token, + group.id, + serde_json::json!({ + "genres": [ + { "name": "Adult", "accessMode": "deny" }, + { "name": "adult", "accessMode": "allow" } + ] + }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_replace_rules_rejects_unknown_library_and_sharing_tag() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_admin_and_token(&db, &state).await; + let group = create_group(&state, &token, "Kids").await; + + let (status, _) = put_rules( + &state, + &token, + group.id, + serde_json::json!({ + "libraries": [{ "libraryId": uuid::Uuid::new_v4(), "accessMode": "allow" }] + }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + let (status, _) = put_rules( + &state, + &token, + group.id, + serde_json::json!({ + "sharingTags": [{ "sharingTagId": uuid::Uuid::new_v4(), "accessMode": "deny" }] + }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_rules_endpoints_require_admin() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_admin_and_token(&db, &state).await; + let (_, reader_token) = create_reader_and_token(&db, &state, "alice").await; + let group = create_group(&state, &token, "Kids").await; + + let (status, _) = put_rules(&state, &reader_token, group.id, serde_json::json!({})).await; + assert_eq!(status, StatusCode::FORBIDDEN); + + let app = create_test_router(state).await; + let request = get_request_with_auth( + &format!("/api/v1/access-groups/{}/rules", group.id), + &reader_token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn test_rules_for_missing_group_is_not_found() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_, token) = create_admin_and_token(&db, &state).await; + + let (status, _) = put_rules(&state, &token, uuid::Uuid::new_v4(), serde_json::json!({})).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} diff --git a/tests/api/content_visibility.rs b/tests/api/content_visibility.rs new file mode 100644 index 000000000..2c45dd301 --- /dev/null +++ b/tests/api/content_visibility.rs @@ -0,0 +1,1236 @@ +//! What a restricted reader can reach, on every surface. +//! +//! One fixture: a reader in a group that denies the Adult genre and the whole +//! "Secret" library. Each surface is then asked about a visible series/book +//! and a hidden one. A hidden item must answer exactly like a missing one +//! (404, or absent from a list); the visible one must still work, so an +//! endpoint that is simply broken cannot pass. + +#[path = "../common/mod.rs"] +mod common; + +use codex::db::ScanningStrategy; +use codex::db::entities::user_access_groups::MembershipSource; +use codex::db::entities::user_sharing_tags::AccessMode; +use codex::db::repositories::{ + AccessGroupRepository, AccessGroupRules, AccessGroupRulesRepository, BookMetadataRepository, + BookRepository, GenreRepository, LibraryRepository, LibraryRule, NameRule, PageRepository, + SeriesRepository, TagRepository, UserRepository, +}; +use common::*; +use hyper::StatusCode; +use std::path::Path; +use std::sync::Arc; +use tempfile::TempDir; +use uuid::Uuid; + +pub struct Item { + pub series_id: Uuid, + pub book_id: Uuid, + pub library_id: Uuid, +} + +pub struct World { + pub state: Arc, + pub reader_token: String, + pub reader_id: Uuid, + /// In the main library, no Adult genre + pub visible: Item, + /// In the main library, Adult genre + pub hidden: Item, + /// In the denied library + pub secret: Item, + _db_dir: TempDir, + _files: TempDir, +} + +impl World { + pub async fn new() -> Self { + let (db, db_dir) = setup_test_db().await; + let files = TempDir::new().unwrap(); + + let main = LibraryRepository::create( + &db, + "Main", + files.path().join("main").to_str().unwrap(), + ScanningStrategy::Default, + ) + .await + .unwrap(); + let secret_lib = LibraryRepository::create( + &db, + "Secret", + files.path().join("secret").to_str().unwrap(), + ScanningStrategy::Default, + ) + .await + .unwrap(); + + let visible = seed(&db, &files, main.id, "Visible", &["Action"], "shonen").await; + let hidden = seed(&db, &files, main.id, "Hidden", &["Adult"], "mature").await; + let secret = seed(&db, &files, secret_lib.id, "Secret series", &[], "secret").await; + + let password_hash = codex::utils::password::hash_password("reader123").unwrap(); + let reader = UserRepository::create( + &db, + &create_test_user("kid", "kid@example.com", &password_hash, false), + ) + .await + .unwrap(); + let group = AccessGroupRepository::create(&db, "Kids", None) + .await + .unwrap(); + AccessGroupRulesRepository::replace( + &db, + group.id, + &AccessGroupRules { + genres: vec![NameRule::new("Adult", AccessMode::Deny)], + libraries: vec![LibraryRule { + library_id: secret_lib.id, + access_mode: AccessMode::Deny, + }], + ..Default::default() + }, + ) + .await + .unwrap(); + AccessGroupRepository::add_member(&db, group.id, reader.id, MembershipSource::Manual) + .await + .unwrap(); + + let state = create_test_app_state(db.clone()).await; + let reader_token = state + .jwt_service + .generate_token(reader.id, reader.username.clone(), reader.get_role()) + .unwrap(); + + Self { + state, + reader_token, + reader_id: reader.id, + visible, + hidden, + secret, + _db_dir: db_dir, + _files: files, + } + } + + /// Router with every optional API enabled. + pub fn router(&self) -> axum::Router { + let mut config = create_test_config_with_komga(); + config.koreader_api.enabled = true; + codex::api::routes::create_router(self.state.clone(), &config) + } + + pub fn path(&self, template: &str, item: &Item) -> String { + template + .replace("{series}", &item.series_id.to_string()) + .replace("{book}", &item.book_id.to_string()) + .replace("{library}", &item.library_id.to_string()) + } + + pub async fn get(&self, uri: &str) -> (StatusCode, Vec) { + let request = get_request_with_auth(uri, &self.reader_token); + make_raw_request(self.router(), request).await + } + + pub async fn get_json(&self, uri: &str) -> (StatusCode, serde_json::Value) { + let (status, body) = self.get(uri).await; + let json = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null); + (status, json) + } +} + +async fn seed( + db: &sea_orm::DatabaseConnection, + files: &TempDir, + library_id: Uuid, + name: &str, + genres: &[&str], + tag: &str, +) -> Item { + let series = SeriesRepository::create(db, library_id, name, None) + .await + .unwrap(); + for genre in genres { + GenreRepository::add_genre_to_series(db, series.id, genre) + .await + .unwrap(); + } + TagRepository::add_tag_to_series(db, series.id, tag) + .await + .unwrap(); + + let dir = TempDir::new_in(files.path()).unwrap(); + let cbz = create_test_cbz(&dir, 3, true); + let file = files.path().join(format!("{}.cbz", Uuid::new_v4())); + std::fs::copy(&cbz, &file).unwrap(); + let book = BookRepository::create(db, &book_model(series.id, library_id, &file, 3), None) + .await + .unwrap(); + BookMetadataRepository::create_with_title_and_number(db, book.id, Some(name.to_string()), None) + .await + .unwrap(); + for n in 1..=3 { + PageRepository::create( + db, + &codex::db::entities::pages::Model { + id: Uuid::new_v4(), + book_id: book.id, + page_number: n, + file_name: format!("page{n:03}.png"), + format: "png".to_string(), + width: 800, + height: 1200, + file_size: 50_000, + created_at: chrono::Utc::now(), + }, + ) + .await + .unwrap(); + } + + Item { + series_id: series.id, + book_id: book.id, + library_id, + } +} + +fn book_model( + series_id: Uuid, + library_id: Uuid, + path: &Path, + page_count: i32, +) -> codex::db::entities::books::Model { + let now = chrono::Utc::now(); + codex::db::entities::books::Model { + id: Uuid::new_v4(), + series_id, + library_id, + path: path.to_str().unwrap().to_string(), + file_name: path.file_name().unwrap().to_str().unwrap().to_string(), + file_size: std::fs::metadata(path).map(|m| m.len() as i64).unwrap_or(0), + file_hash: format!("hash_{}", Uuid::new_v4()), + partial_hash: String::new(), + format: "cbz".to_string(), + page_count, + deleted: false, + analyzed: true, + analysis_error: None, + analysis_errors: None, + modified_at: now, + created_at: now, + updated_at: now, + thumbnail_path: None, + thumbnail_generated_at: None, + koreader_hash: Some(format!("kohash-{}", Uuid::new_v4())), + epub_positions: None, + epub_spine_items: None, + } +} + +/// Assert a by-id endpoint hides the hidden and secret items and still serves +/// the visible one. Collects every failure before panicking, so one run shows +/// the whole list. +async fn assert_by_id_reads(world: &World, templates: &[&str]) { + let mut failures = Vec::new(); + for template in templates { + let visible = world.get(&world.path(template, &world.visible)).await.0; + if visible == StatusCode::NOT_FOUND || visible.is_server_error() { + failures.push(format!("{template}: visible item answered {visible}")); + } + for (label, item) in [("hidden", &world.hidden), ("secret", &world.secret)] { + let status = world.get(&world.path(template, item)).await.0; + if status != StatusCode::NOT_FOUND { + failures.push(format!("{template}: {label} item answered {status}")); + } + } + } + assert!(failures.is_empty(), "\n{}", failures.join("\n")); +} + +// ============================================================================ +// v1 by-id reads +// ============================================================================ + +#[tokio::test] +async fn v1_book_reads_hide_hidden_books() { + let world = World::new().await; + assert_by_id_reads( + &world, + &[ + "/api/v1/books/{book}", + "/api/v1/books/{book}/full", + "/api/v1/books/{book}/file", + "/api/v1/books/{book}/pages", + "/api/v1/books/{book}/pages/1", + "/api/v1/books/{book}/thumbnail", + "/api/v1/books/{book}/adjacent", + "/api/v1/books/{book}/metadata/locks", + "/api/v1/books/{book}/external-ids", + "/api/v1/books/{book}/external-links", + "/api/v1/books/{book}/covers", + "/api/v1/books/{book}/genres", + "/api/v1/books/{book}/tags", + "/api/v1/books/{book}/readlists", + "/api/v1/books/{book}/progress", + "/api/v1/books/{book}/progression", + "/api/v1/books/{book}/read-history", + ], + ) + .await; +} + +#[tokio::test] +async fn v1_series_reads_hide_hidden_series() { + let world = World::new().await; + assert_by_id_reads( + &world, + &[ + "/api/v1/series/{series}", + "/api/v1/series/{series}/full", + "/api/v1/series/{series}/books", + "/api/v1/series/{series}/thumbnail", + "/api/v1/series/{series}/download", + "/api/v1/series/{series}/metadata", + "/api/v1/series/{series}/metadata/locks", + "/api/v1/series/{series}/genres", + "/api/v1/series/{series}/tags", + "/api/v1/series/{series}/rating", + "/api/v1/series/{series}/alternate-titles", + "/api/v1/series/{series}/external-ratings", + "/api/v1/series/{series}/ratings/average", + "/api/v1/series/{series}/external-links", + "/api/v1/series/{series}/external-ids", + "/api/v1/series/{series}/covers", + "/api/v1/series/{series}/collections", + "/api/v1/series/{series}/tracking", + "/api/v1/series/{series}/aliases", + "/api/v1/series/{series}/releases", + "/api/v1/series/{series}/read-history", + "/api/v1/user/series/{series}/reader-settings", + ], + ) + .await; +} + +/// Needs a plugin to succeed, so the visible series fails too; what matters is +/// that a hidden series fails as "not found" before any plugin is looked up. +#[tokio::test] +async fn v1_series_search_title_hides_hidden_series() { + let world = World::new().await; + let template = "/api/v1/series/{series}/metadata/search-title?pluginId=00000000-0000-0000-0000-000000000000"; + + let (_, visible) = world.get_json(&world.path(template, &world.visible)).await; + assert_ne!(visible["message"], "Series not found", "{visible}"); + + for item in [&world.hidden, &world.secret] { + let (status, body) = world.get_json(&world.path(template, item)).await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(body["message"], "Series not found", "{body}"); + } +} + +// ============================================================================ +// v1 writes a reader can make +// ============================================================================ + +impl World { + pub async fn send( + &self, + method: &str, + uri: &str, + body: Option, + ) -> (StatusCode, serde_json::Value) { + let mut builder = hyper::Request::builder() + .method(method) + .uri(uri) + .header("Authorization", format!("Bearer {}", self.reader_token)); + if body.is_some() { + builder = builder.header("Content-Type", "application/json"); + } + let request = builder + .body(body.map(|b| b.to_string()).unwrap_or_default()) + .unwrap(); + let (status, bytes) = make_raw_request(self.router(), request).await; + ( + status, + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null), + ) + } +} + +/// Like [`assert_by_id_reads`], for writes. The visible item goes last for +/// each endpoint so a write on it cannot mask what the hidden items answer. +async fn assert_by_id_writes(world: &World, cases: &[(&str, &str, Option)]) { + let mut failures = Vec::new(); + for (method, template, body) in cases { + for (label, item) in [("hidden", &world.hidden), ("secret", &world.secret)] { + let (status, _) = world + .send(method, &world.path(template, item), body.clone()) + .await; + if status != StatusCode::NOT_FOUND { + failures.push(format!( + "{method} {template}: {label} item answered {status}" + )); + } + } + let (status, response) = world + .send(method, &world.path(template, &world.visible), body.clone()) + .await; + if status == StatusCode::NOT_FOUND || status.is_server_error() { + failures.push(format!( + "{method} {template}: visible item answered {status} {response}" + )); + } + } + assert!(failures.is_empty(), "\n{}", failures.join("\n")); +} + +#[tokio::test] +async fn v1_reader_writes_refuse_hidden_content() { + use serde_json::json; + let world = World::new().await; + assert_by_id_writes( + &world, + &[ + ( + "PUT", + "/api/v1/books/{book}/progress", + Some(json!({ "currentPage": 1 })), + ), + ("DELETE", "/api/v1/books/{book}/progress", None), + ("POST", "/api/v1/books/{book}/read", None), + ("POST", "/api/v1/books/{book}/unread", None), + ("DELETE", "/api/v1/books/{book}/read-history", None), + ( + "PUT", + "/api/v1/series/{series}/rating", + Some(json!({ "rating": 80 })), + ), + ("DELETE", "/api/v1/series/{series}/rating", None), + ("POST", "/api/v1/series/{series}/read", None), + ("POST", "/api/v1/series/{series}/unread", None), + ("DELETE", "/api/v1/series/{series}/read-history", None), + ( + "PATCH", + "/api/v1/user/series/{series}/reader-settings", + Some(json!({ "readingDirection": "rtl" })), + ), + ( + "DELETE", + "/api/v1/user/series/{series}/reader-settings", + None, + ), + ], + ) + .await; +} + +#[tokio::test] +async fn v1_want_to_read_refuses_hidden_content() { + use serde_json::json; + let world = World::new().await; + + for item in [&world.hidden, &world.secret] { + let (status, _) = world + .send( + "POST", + "/api/v1/want-to-read", + Some(json!({ "seriesId": item.series_id })), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + let (status, _) = world + .send( + "POST", + "/api/v1/want-to-read", + Some(json!({ "bookId": item.book_id })), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + let (status, body) = world + .send( + "POST", + "/api/v1/want-to-read/bulk", + Some(json!({ + "seriesIds": [world.visible.series_id, world.hidden.series_id, world.secret.series_id], + "bookIds": [world.visible.book_id, world.hidden.book_id, world.secret.book_id], + })), + ) + .await; + assert!(status.is_success(), "{status} {body}"); + assert_eq!(body["added"], 2, "only the visible series and book: {body}"); +} + +#[tokio::test] +async fn v1_bulk_read_skips_hidden_content() { + use serde_json::json; + let world = World::new().await; + let all_books = json!({ + "bookIds": [world.visible.book_id, world.hidden.book_id, world.secret.book_id] + }); + let all_series = json!({ + "seriesIds": [world.visible.series_id, world.hidden.series_id, world.secret.series_id] + }); + + // Each step changes only the visible item, so each counts exactly one. + let (_, body) = world + .send("POST", "/api/v1/series/bulk/read", Some(all_series.clone())) + .await; + assert_eq!(body["count"], 1, "series read: {body}"); + let (_, body) = world + .send("POST", "/api/v1/series/bulk/unread", Some(all_series)) + .await; + assert_eq!(body["count"], 1, "series unread: {body}"); + let (_, body) = world + .send("POST", "/api/v1/books/bulk/read", Some(all_books.clone())) + .await; + assert_eq!(body["count"], 1, "books read: {body}"); + let (_, body) = world + .send("POST", "/api/v1/books/bulk/unread", Some(all_books)) + .await; + assert_eq!(body["count"], 1, "books unread: {body}"); +} + +#[tokio::test] +async fn v1_reading_sessions_reject_hidden_books() { + use serde_json::json; + let world = World::new().await; + let session = |book_id: Uuid| { + let now = chrono::Utc::now(); + json!({ + "id": Uuid::new_v4(), + "bookId": book_id, + "deviceId": "device-1", + "kind": "progress", + "toPage": 2, + "clientStartedAt": (now - chrono::Duration::minutes(5)).to_rfc3339(), + "clientEndedAt": now.to_rfc3339(), + "activeDurationMs": 60000, + "pagesRead": 1 + }) + }; + let (status, body) = world + .send( + "POST", + "/api/v1/reading-sessions", + Some(json!({ "sessions": [ + session(world.visible.book_id), + session(world.hidden.book_id), + session(world.secret.book_id), + ]})), + ) + .await; + assert!(status.is_success(), "{status} {body}"); + let rejected = body["rejected"].as_array().cloned().unwrap_or_default(); + assert_eq!(rejected.len(), 2, "{body}"); + assert!( + rejected.iter().all(|r| r["reason"] == "book_not_found"), + "{body}" + ); +} + +// ============================================================================ +// OPDS 1.2 and 2.0 by-id reads +// ============================================================================ + +#[tokio::test] +async fn opds_reads_hide_hidden_content() { + let world = World::new().await; + assert_by_id_reads( + &world, + &[ + "/opds/series/{series}", + "/opds/books/{book}/pages", + "/opds/books/{book}/pages/1", + "/opds/v2/series/{series}", + ], + ) + .await; +} + +// ============================================================================ +// Komga by-id reads and writes +// ============================================================================ + +#[tokio::test] +async fn komga_reads_hide_hidden_content() { + let world = World::new().await; + assert_by_id_reads( + &world, + &[ + "/komga/api/v1/series/{series}", + "/komga/api/v1/series/{series}/thumbnail", + "/komga/api/v1/series/{series}/books", + "/komga/api/v1/series/{series}/collections", + "/komga/api/v1/books/{book}", + "/komga/api/v1/books/{book}/thumbnail", + "/komga/api/v1/books/{book}/file", + "/komga/api/v1/books/{book}/pages", + "/komga/api/v1/books/{book}/pages/1", + "/komga/api/v1/books/{book}/pages/1/thumbnail", + "/komga/api/v1/books/{book}/readlists", + "/komga/api/v1/books/{book}/progression", + ], + ) + .await; +} + +#[tokio::test] +async fn komga_writes_refuse_hidden_content() { + use serde_json::json; + let world = World::new().await; + assert_by_id_writes( + &world, + &[ + ( + "PATCH", + "/komga/api/v1/books/{book}/read-progress", + Some(json!({ "page": 1 })), + ), + ("DELETE", "/komga/api/v1/books/{book}/read-progress", None), + ("POST", "/komga/api/v1/series/{series}/read-progress", None), + ( + "DELETE", + "/komga/api/v1/series/{series}/read-progress", + None, + ), + ], + ) + .await; +} + +// ============================================================================ +// Libraries +// ============================================================================ + +#[tokio::test] +async fn library_by_id_endpoints_hide_a_denied_library() { + let world = World::new().await; + let mut failures = Vec::new(); + for template in [ + "/api/v1/libraries/{library}", + "/api/v1/libraries/{library}/series", + "/api/v1/libraries/{library}/books", + "/api/v1/libraries/{library}/series/recently-added", + "/api/v1/libraries/{library}/books/recently-added", + "/opds/libraries/{library}", + "/opds/v2/libraries/{library}", + "/komga/api/v1/libraries/{library}", + ] { + let visible = world.get(&world.path(template, &world.visible)).await.0; + if visible == StatusCode::NOT_FOUND || visible.is_server_error() { + failures.push(format!("{template}: main library answered {visible}")); + } + let secret = world.get(&world.path(template, &world.secret)).await.0; + if secret != StatusCode::NOT_FOUND { + failures.push(format!("{template}: secret library answered {secret}")); + } + } + assert!(failures.is_empty(), "\n{}", failures.join("\n")); +} + +#[tokio::test] +async fn library_lists_leave_out_a_denied_library() { + let world = World::new().await; + let secret = world.secret.library_id.to_string(); + let main = world.visible.library_id.to_string(); + + let (status, body) = world.get_json("/api/v1/libraries").await; + assert_eq!(status, StatusCode::OK); + let ids: Vec = body["data"] + .as_array() + .unwrap() + .iter() + .map(|l| l["id"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(ids, vec![main.clone()], "v1"); + + let (status, body) = world.get_json("/komga/api/v1/libraries").await; + assert_eq!(status, StatusCode::OK); + let ids: Vec<&str> = body + .as_array() + .unwrap() + .iter() + .map(|l| l["id"].as_str().unwrap()) + .collect(); + assert_eq!(ids, vec![main.as_str()], "komga"); + + for feed in ["/opds/libraries", "/opds/v2/libraries"] { + let (status, body) = world.get(feed).await; + assert_eq!(status, StatusCode::OK, "{feed}"); + let text = String::from_utf8_lossy(&body); + assert!(text.contains(&main), "{feed} lists the main library"); + assert!(!text.contains(&secret), "{feed} leaks the secret library"); + assert!( + !text.contains("Secret"), + "{feed} leaks the secret library name" + ); + } +} + +#[tokio::test] +async fn library_counts_only_include_visible_content() { + let world = World::new().await; + let (_, body) = world + .get_json(&world.path("/api/v1/libraries/{library}", &world.visible)) + .await; + // The main library holds the visible and the hidden series + assert_eq!(body["seriesCount"], 1, "{body}"); + assert_eq!(body["bookCount"], 1, "{body}"); +} + +// ============================================================================ +// Lists +// ============================================================================ + +/// The ids in a response, wherever they appear. +fn mentions(body: &[u8], id: Uuid) -> bool { + String::from_utf8_lossy(body).contains(&id.to_string()) +} + +/// Assert a list response mentions the visible item and neither hidden one. +fn assert_list(label: &str, body: &[u8], pick: impl Fn(&Item) -> Uuid, world: &World) { + assert!( + mentions(body, pick(&world.visible)), + "{label}: visible item missing" + ); + assert!( + !mentions(body, pick(&world.hidden)), + "{label}: hidden item listed" + ); + assert!( + !mentions(body, pick(&world.secret)), + "{label}: secret item listed" + ); +} + +#[tokio::test] +async fn komga_lists_leave_out_hidden_content() { + use serde_json::json; + let world = World::new().await; + let series = |i: &Item| i.series_id; + let book = |i: &Item| i.book_id; + + for uri in [ + "/komga/api/v1/series?size=100", + "/komga/api/v1/series/new?size=100", + "/komga/api/v1/series/updated?size=100", + ] { + let (status, body) = world.get(uri).await; + assert_eq!(status, StatusCode::OK, "{uri}"); + assert_list(uri, &body, series, &world); + } + + let (status, body) = world + .send( + "POST", + "/komga/api/v1/series/list?size=100", + Some(json!({})), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_list( + "POST series/list", + body.to_string().as_bytes(), + series, + &world, + ); + + let (status, body) = world + .send("POST", "/komga/api/v1/books/list?size=100", Some(json!({}))) + .await; + assert_eq!(status, StatusCode::OK); + assert_list("POST books/list", body.to_string().as_bytes(), book, &world); +} + +#[tokio::test] +async fn genre_and_tag_lists_leave_out_hidden_names() { + let world = World::new().await; + + let (_, body) = world.get_json("/api/v1/genres?pageSize=100").await; + let names: Vec<&str> = body["data"] + .as_array() + .unwrap() + .iter() + .map(|g| g["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["Action"], "v1 genres"); + assert_eq!(body["data"][0]["seriesCount"], 1); + + let (_, body) = world.get_json("/api/v1/tags?pageSize=100").await; + let names: Vec<&str> = body["data"] + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["shonen"], "v1 tags"); + + let (_, body) = world.get_json("/komga/api/v1/genres").await; + assert_eq!(body, serde_json::json!(["Action"]), "komga genres"); + let (_, body) = world.get_json("/komga/api/v1/tags").await; + assert_eq!(body, serde_json::json!(["shonen"]), "komga tags"); +} + +#[tokio::test] +async fn opds_search_leaves_out_hidden_series() { + let world = World::new().await; + for uri in ["/opds/search?q=e", "/opds/v2/search?query=e"] { + let (status, body) = world.get(uri).await; + assert_eq!(status, StatusCode::OK, "{uri}"); + let text = String::from_utf8_lossy(&body); + assert!( + text.contains(&world.visible.series_id.to_string()), + "{uri}: visible missing" + ); + assert!( + !text.contains(&world.hidden.series_id.to_string()), + "{uri}: hidden listed" + ); + assert!( + !text.contains(&world.secret.series_id.to_string()), + "{uri}: secret listed" + ); + } +} + +#[tokio::test] +async fn books_with_errors_leave_out_hidden_books() { + use codex::db::entities::books; + use sea_orm::{ActiveModelTrait, EntityTrait, Set}; + let world = World::new().await; + let errors = r#"{"parser":{"message":"broken","occurred_at":"2026-01-01T00:00:00Z"}}"#; + for item in [&world.visible, &world.hidden, &world.secret] { + let book = books::Entity::find_by_id(item.book_id) + .one(&world.state.db) + .await + .unwrap() + .unwrap(); + let mut active: books::ActiveModel = book.into(); + active.analysis_errors = Set(Some(errors.to_string())); + active.update(&world.state.db).await.unwrap(); + } + + let (status, body) = world.get("/api/v1/books/errors").await; + assert_eq!(status, StatusCode::OK); + assert_list("books/errors", &body, |i| i.book_id, &world); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["totalBooksWithErrors"], 1, "{json}"); +} + +#[tokio::test] +async fn duplicate_groups_only_show_visible_members() { + use codex::db::entities::{book_duplicates, series_duplicates}; + use sea_orm::{ActiveModelTrait, Set}; + let world = World::new().await; + let now = chrono::Utc::now(); + + // Visible + hidden: only one visible member left, so the group disappears + book_duplicates::ActiveModel { + id: Set(Uuid::new_v4()), + file_hash: Set("h".to_string()), + book_ids: Set( + serde_json::to_string(&[world.visible.book_id, world.hidden.book_id]).unwrap(), + ), + duplicate_count: Set(2), + created_at: Set(now), + updated_at: Set(now), + } + .insert(&world.state.db) + .await + .unwrap(); + series_duplicates::ActiveModel { + id: Set(Uuid::new_v4()), + match_type: Set("title".to_string()), + match_key: Set("hidden".to_string()), + library_id: Set(Some(world.visible.library_id)), + series_ids: Set( + serde_json::to_string(&[world.visible.series_id, world.hidden.series_id]).unwrap(), + ), + duplicate_count: Set(2), + created_at: Set(now), + updated_at: Set(now), + } + .insert(&world.state.db) + .await + .unwrap(); + + let (status, body) = world.get_json("/api/v1/duplicates").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["totalGroups"], 0, "{body}"); + let (status, body) = world.get_json("/api/v1/duplicates/series").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["totalGroups"], 0, "{body}"); +} + +#[tokio::test] +async fn inventory_metrics_are_refused_to_restricted_readers() { + let world = World::new().await; + let (status, _) = world.get("/api/v1/metrics/inventory").await; + assert_eq!(status, StatusCode::FORBIDDEN); +} + +/// Recommendations say whether the recommended series is already in Codex; +/// for a hidden series that would reveal it exists. +#[tokio::test] +async fn recommendations_do_not_reveal_hidden_series() { + use codex::db::repositories::{ + PluginsRepository, SeriesExternalIdRepository, UserPluginDataRepository, + UserPluginsRepository, + }; + use serde_json::json; + + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| { + if std::env::var("CODEX_ENCRYPTION_KEY").is_err() { + // SAFETY: set once, before any test in this process reads it. + unsafe { + std::env::set_var( + "CODEX_ENCRYPTION_KEY", + "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleTEyMzQ=", + ); + } + } + }); + + let world = World::new().await; + let db = &world.state.db; + let plugin = PluginsRepository::create( + db, + "recs", + "Recs", + None, + "user", + "echo", + vec![], + vec![], + None, + vec![], + vec![], + vec![], + None, + "none", + None, + true, + None, + None, + None, + None, + ) + .await + .unwrap(); + PluginsRepository::update_manifest( + db, + plugin.id, + Some(json!({ + "name": "recs", + "displayName": "Recs", + "version": "1.0.0", + "protocolVersion": "1.0", + "pluginType": "user", + "capabilities": { + "userRecommendationProvider": true, + "externalIdSource": "anilist" + } + })), + ) + .await + .unwrap(); + let (status, _) = world + .send( + "POST", + &format!("/api/v1/user/plugins/{}/enable", plugin.id), + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + + for (item, external_id) in [ + (&world.visible, "1"), + (&world.hidden, "2"), + (&world.secret, "3"), + ] { + SeriesExternalIdRepository::create(db, item.series_id, "anilist", external_id, None, None) + .await + .unwrap(); + } + let reader = UserRepository::get_by_username(db, "kid") + .await + .unwrap() + .unwrap(); + let instance = UserPluginsRepository::get_by_user_and_plugin(db, reader.id, plugin.id) + .await + .unwrap() + .unwrap(); + UserPluginDataRepository::set( + db, + instance.id, + "recommendations", + json!({ + "recommendations": [ + { "externalId": "1", "title": "Visible", "score": 0.9, "reason": "r" }, + { "externalId": "2", "title": "Hidden", "score": 0.9, "reason": "r" }, + { "externalId": "3", "title": "Secret", "score": 0.9, "reason": "r" } + ], + "generatedAt": "2026-01-01T00:00:00Z", + "cached": true + }), + None, + ) + .await + .unwrap(); + + let (status, body) = world.get_json("/api/v1/user/recommendations").await; + assert_eq!(status, StatusCode::OK, "{body}"); + let recs = body["recommendations"].as_array().unwrap(); + let in_codex = |id: &str| { + recs.iter() + .find(|r| r["externalId"] == id) + .map(|r| r["inCodex"].as_bool().unwrap_or(false)) + }; + assert_eq!(in_codex("1"), Some(true), "{body}"); + assert_eq!(in_codex("2"), Some(false), "{body}"); + assert_eq!(in_codex("3"), Some(false), "{body}"); + let text = body.to_string(); + assert!(!text.contains(&world.hidden.series_id.to_string())); + assert!(!text.contains(&world.secret.series_id.to_string())); +} + +// ============================================================================ +// Event streams +// ============================================================================ + +#[tokio::test] +async fn entity_events_about_hidden_content_are_not_sent() { + use codex::api::visibility::entity_event_visible; + use codex::events::{EntityChangeEvent, EntityEvent, EntityType}; + let world = World::new().await; + let visible_to_reader = |event: EntityEvent| { + let event = EntityChangeEvent::new(event, None); + let db = world.state.db.clone(); + let user = world.reader_id; + async move { entity_event_visible(&db, user, &event).await } + }; + + for (label, item, expected) in [ + ("visible", &world.visible, true), + ("hidden", &world.hidden, false), + ("secret", &world.secret, false), + ] { + let events = [ + EntityEvent::BookUpdated { + book_id: item.book_id, + series_id: item.series_id, + library_id: item.library_id, + fields: None, + }, + EntityEvent::SeriesUpdated { + series_id: item.series_id, + library_id: item.library_id, + fields: None, + }, + EntityEvent::CoverUpdated { + entity_type: EntityType::Book, + entity_id: item.book_id, + library_id: Some(item.library_id), + }, + EntityEvent::CoverUpdated { + entity_type: EntityType::Series, + entity_id: item.series_id, + library_id: Some(item.library_id), + }, + ]; + for event in events { + let debug = format!("{event:?}"); + assert_eq!(visible_to_reader(event).await, expected, "{label}: {debug}"); + } + } + + // A library-level event for the denied library is withheld + assert!( + !visible_to_reader(EntityEvent::LibraryUpdated { + library_id: world.secret.library_id + }) + .await + ); + assert!( + visible_to_reader(EntityEvent::LibraryUpdated { + library_id: world.visible.library_id + }) + .await + ); +} + +#[tokio::test] +async fn task_events_about_hidden_content_are_not_sent() { + use codex::api::visibility::task_event_visible; + use codex::events::TaskProgressEvent; + let world = World::new().await; + + for (item, expected) in [ + (&world.visible, true), + (&world.hidden, false), + (&world.secret, false), + ] { + let event = TaskProgressEvent::started( + Uuid::new_v4(), + "analyze_book", + Some(item.library_id), + Some(item.series_id), + Some(item.book_id), + ); + assert_eq!( + task_event_visible(&world.state.db, world.reader_id, &event).await, + expected + ); + } +} + +// ============================================================================ +// KOReader +// ============================================================================ + +#[tokio::test] +async fn koreader_sync_treats_hidden_books_as_missing() { + use codex::db::entities::books; + use sea_orm::EntityTrait; + let world = World::new().await; + let hash_of = |item: &Item| { + let db = world.state.db.clone(); + let id = item.book_id; + async move { + books::Entity::find_by_id(id) + .one(&db) + .await + .unwrap() + .unwrap() + .koreader_hash + .unwrap() + } + }; + + for (item, visible) in [ + (&world.visible, true), + (&world.hidden, false), + (&world.secret, false), + ] { + let hash = hash_of(item).await; + + let (status, body) = world + .send( + "PUT", + "/koreader/syncs/progress", + Some(serde_json::json!({ + "document": hash, + "progress": "2", + "percentage": 0.5, + "device": "Kobo", + "device_id": "kobo-1" + })), + ) + .await; + if visible { + assert!(status.is_success(), "visible PUT: {status} {body}"); + } else { + assert_eq!(status, StatusCode::NOT_FOUND, "hidden PUT: {body}"); + } + + let (status, body) = world + .get_json(&format!("/koreader/syncs/progress/{hash}")) + .await; + if visible { + assert!(status.is_success(), "visible GET: {status} {body}"); + } else { + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(body["message"], "No book found with this hash", "{body}"); + } + } +} + +// ============================================================================ +// Visibility preview (admin tool) +// ============================================================================ + +#[tokio::test] +async fn admins_can_preview_what_a_reader_sees_and_why() { + let world = World::new().await; + let password_hash = codex::utils::password::hash_password("admin123").unwrap(); + let admin = UserRepository::create( + &world.state.db, + &create_test_user("boss", "boss@example.com", &password_hash, true), + ) + .await + .unwrap(); + let admin_token = world + .state + .jwt_service + .generate_token(admin.id, admin.username.clone(), admin.get_role()) + .unwrap(); + let get = |uri: String, token: String| { + let app = world.router(); + async move { + let (status, body) = make_raw_request(app, get_request_with_auth(&uri, &token)).await; + ( + status, + serde_json::from_slice::(&body).unwrap_or_default(), + ) + } + }; + let preview = format!("/api/v1/users/{}/visibility-preview", world.reader_id); + + let (status, body) = get(preview.clone(), admin_token.clone()).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["restricted"], true); + assert_eq!(body["roleBypass"], false); + assert_eq!(body["visibleSeriesCount"], 1); + assert_eq!(body["hiddenSeriesCount"], 2); + let library_visible = |id: Uuid| { + body["libraries"] + .as_array() + .unwrap() + .iter() + .find(|l| l["id"] == id.to_string()) + .map(|l| l["visible"].clone()) + }; + assert_eq!(library_visible(world.visible.library_id), Some(true.into())); + assert_eq!(library_visible(world.secret.library_id), Some(false.into())); + + let explain = |item: &Item| format!("{preview}?seriesId={}", item.series_id); + let (_, body) = get(explain(&world.hidden), admin_token.clone()).await; + let series = &body["series"]; + assert_eq!(series["visible"], false, "{body}"); + assert_eq!(series["decision"], "denied"); + assert_eq!(series["deniedBy"][0]["ruleType"], "genre"); + assert_eq!(series["deniedBy"][0]["value"], "Adult"); + assert_eq!(series["deniedBy"][0]["source"]["groupName"], "Kids"); + + let (_, body) = get(explain(&world.secret), admin_token.clone()).await; + assert_eq!( + body["series"]["deniedBy"][0]["ruleType"], "library", + "{body}" + ); + assert_eq!(body["series"]["deniedBy"][0]["value"], "Secret"); + + let (_, body) = get(explain(&world.visible), admin_token.clone()).await; + assert_eq!(body["series"]["visible"], true, "{body}"); + assert_eq!(body["series"]["decision"], "open"); + + // Admins are reported as exempt + let (_, body) = get( + format!("/api/v1/users/{}/visibility-preview", admin.id), + admin_token, + ) + .await; + assert_eq!(body["roleBypass"], true, "{body}"); + assert_eq!(body["restricted"], false); + + // Readers cannot use it + let (status, _) = get(preview, world.reader_token.clone()).await; + assert_eq!(status, StatusCode::FORBIDDEN); +} diff --git a/tests/api/metrics.rs b/tests/api/metrics.rs index 056350d20..430a73b8d 100644 --- a/tests/api/metrics.rs +++ b/tests/api/metrics.rs @@ -169,8 +169,10 @@ async fn test_get_metrics_without_auth() { assert_eq!(error.error, "Unauthorized"); } +/// Inventory metrics describe the whole server (user count, database size, +/// every library's totals), so only admins may read them. #[tokio::test] -async fn test_get_metrics_with_readonly_user() { +async fn test_get_metrics_is_refused_to_readonly_user() { let (db, _temp_dir) = setup_test_db().await; // Create a library @@ -183,12 +185,9 @@ async fn test_get_metrics_with_readonly_user() { let app = create_test_router(state).await; let request = get_request_with_auth("/api/v1/metrics/inventory", &token); - let (status, response): (StatusCode, Option) = - make_json_request(app, request).await; + let (status, _): (StatusCode, Option) = make_json_request(app, request).await; - assert_eq!(status, StatusCode::OK); - let metrics = response.unwrap(); - assert_eq!(metrics.library_count, 1); + assert_eq!(status, StatusCode::FORBIDDEN); } #[tokio::test] @@ -414,7 +413,7 @@ async fn test_get_metrics_exact_page_count_requires_admin() { } #[tokio::test] -async fn test_get_metrics_exact_false_is_allowed_for_readonly_user() { +async fn test_get_metrics_exact_false_is_refused_to_readonly_user() { let (db, _temp_dir) = setup_test_db().await; let state = create_test_auth_state(db.clone()).await; @@ -422,7 +421,33 @@ async fn test_get_metrics_exact_false_is_allowed_for_readonly_user() { let app = create_test_router(state).await; let request = get_request_with_auth("/api/v1/metrics/inventory?exact=false", &token); - let (status, _): (StatusCode, Option) = make_json_request(app, request).await; + let (status, _): (StatusCode, Option) = make_json_request(app, request).await; - assert_eq!(status, StatusCode::OK); + assert_eq!(status, StatusCode::FORBIDDEN); +} + +/// Maintainers run tasks and the iOS app shows them metrics from its Tasks +/// screen, so metrics reads are open to them, though not to readers. Task +/// metrics are covered in `task_metrics.rs`, which wires the metrics service. +#[tokio::test] +async fn test_metrics_reads_are_open_to_maintainers() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + + let password_hash = password::hash_password("maint123").unwrap(); + let mut user = create_test_user("maint", "maint@example.com", &password_hash, false); + user.role = "maintainer".to_string(); + let created = UserRepository::create(&db, &user).await.unwrap(); + let token = state + .jwt_service + .generate_token(created.id, created.username.clone(), created.get_role()) + .unwrap(); + + for uri in ["/api/v1/metrics/inventory", "/api/v1/metrics/plugins"] { + let app = create_test_router(state.clone()).await; + let request = get_request_with_auth(uri, &token); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK, "{uri}"); + } } diff --git a/tests/api/mod.rs b/tests/api/mod.rs index bbdb5bc8e..bf1759889 100644 --- a/tests/api/mod.rs +++ b/tests/api/mod.rs @@ -15,6 +15,7 @@ mod books; mod bulk_metadata; mod bulk_operations; mod collections; +mod content_visibility; mod covers; mod current_user; mod duplicates; diff --git a/tests/api/plugin_metrics.rs b/tests/api/plugin_metrics.rs index 4312ef519..8401d13e3 100644 --- a/tests/api/plugin_metrics.rs +++ b/tests/api/plugin_metrics.rs @@ -141,7 +141,7 @@ async fn test_get_plugin_metrics_requires_auth() { } #[tokio::test] -async fn test_get_plugin_metrics_allowed_for_reader() { +async fn test_get_plugin_metrics_refused_to_reader() { let (db, _temp_dir) = setup_test_db().await; let state = create_test_app_state(db.clone()).await; @@ -160,10 +160,9 @@ async fn test_get_plugin_metrics_allowed_for_reader() { let app = create_test_router_with_app_state(state); let request = get_request_with_auth("/api/v1/metrics/plugins", &token); - let (status, response): (StatusCode, Option) = + let (status, _): (StatusCode, Option) = make_json_request(app, request).await; - // Reader should have libraries:read permission which is required - assert_eq!(status, StatusCode::OK); - assert!(response.is_some()); + // Server metrics are admin-only, like the Settings > Metrics page + assert_eq!(status, StatusCode::FORBIDDEN); } diff --git a/tests/api/reading_progress_transfer.rs b/tests/api/reading_progress_transfer.rs index 313fd9aa3..6bfdba92b 100644 --- a/tests/api/reading_progress_transfer.rs +++ b/tests/api/reading_progress_transfer.rs @@ -55,6 +55,26 @@ async fn admin_and_token( (created.id, token) } +async fn reader_and_token( + db: &DatabaseConnection, + state: &codex::api::extractors::AuthState, + username: &str, +) -> (Uuid, String) { + let password_hash = password::hash_password("pw123456").unwrap(); + let user = create_test_user( + username, + &format!("{username}@example.com"), + &password_hash, + false, + ); + let created = UserRepository::create(db, &user).await.unwrap(); + let token = state + .jwt_service + .generate_token(created.id, created.username.clone(), created.get_role()) + .unwrap(); + (created.id, token) +} + fn book_model( series_id: Uuid, library_id: Uuid, @@ -594,7 +614,8 @@ async fn exercise_visibility_denies_unmatched(db: &DatabaseConnection) { // Relative, because the PostgreSQL run shares one database across scenarios. let before = table_counts(db).await; let state = create_test_auth_state(db.clone()).await; - let (user_id, token) = admin_and_token(db, &state, "restricted-reader").await; + // A reader: admins are never restricted by sharing tags. + let (user_id, token) = reader_and_token(db, &state, "restricted-reader").await; let library = LibraryRepository::create(db, "Lib", "/lib", ScanningStrategy::Default) .await diff --git a/tests/api/task_metrics.rs b/tests/api/task_metrics.rs index 11669411d..0a334b02a 100644 --- a/tests/api/task_metrics.rs +++ b/tests/api/task_metrics.rs @@ -278,6 +278,49 @@ async fn test_task_metrics_requires_auth() { assert_eq!(error.error, "Unauthorized"); } +#[tokio::test] +async fn test_task_metrics_reads_are_open_to_maintainers() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_app_state_with_metrics(db.clone()).await; + let password_hash = password::hash_password("maint123").unwrap(); + let mut user = create_test_user("maint", "maint@example.com", &password_hash, false); + user.role = "maintainer".to_string(); + let created = UserRepository::create(&db, &user).await.unwrap(); + let token = state + .jwt_service + .generate_token(created.id, created.username.clone(), created.get_role()) + .unwrap(); + + for uri in [ + "/api/v1/metrics/tasks", + "/api/v1/metrics/tasks/history?days=7&granularity=hour", + ] { + let app = create_test_router_with_app_state(state.clone()); + let request = get_request_with_auth(uri, &token); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK, "{uri}"); + } +} + +#[tokio::test] +async fn test_task_metrics_reads_are_refused_to_readers() { + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_app_state_with_metrics(db.clone()).await; + let token = create_regular_user_and_token(&db, &state).await; + + for uri in [ + "/api/v1/metrics/tasks", + "/api/v1/metrics/tasks/history?days=7&granularity=hour", + ] { + let app = create_test_router_with_app_state(state.clone()); + let request = get_request_with_auth(uri, &token); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{uri}"); + } +} + #[tokio::test] async fn test_metrics_cleanup_requires_admin() { let (db, _temp_dir) = setup_test_db().await; diff --git a/tests/db/access_groups.rs b/tests/db/access_groups.rs new file mode 100644 index 000000000..310439d06 --- /dev/null +++ b/tests/db/access_groups.rs @@ -0,0 +1,62 @@ +#[path = "../common/mod.rs"] +mod common; + +// The single-default rule for access groups is a partial unique index written +// as raw SQL, so it is checked on both backends rather than trusted to SeaQuery. + +use codex::db::entities::access_groups; +use codex::db::repositories::AccessGroupRepository; +use common::*; +use sea_orm::{ActiveModelTrait, DatabaseConnection, Set}; +use uuid::Uuid; + +async fn exercise_single_default(db: &DatabaseConnection) { + let suffix = Uuid::new_v4(); + let a = AccessGroupRepository::create(db, &format!("Default A {suffix}"), None) + .await + .unwrap(); + let b = AccessGroupRepository::create(db, &format!("Default B {suffix}"), None) + .await + .unwrap(); + + AccessGroupRepository::set_default(db, a.id, true) + .await + .unwrap(); + + // Bypassing set_default, a second default must be refused by the index + let mut active: access_groups::ActiveModel = b.clone().into(); + active.is_default = Set(true); + let second = active.update(db).await; + + // Non-default rows are unconstrained + AccessGroupRepository::set_default(db, a.id, false) + .await + .unwrap(); + let c = AccessGroupRepository::create(db, &format!("Default C {suffix}"), None) + .await + .unwrap(); + + // Clean up before asserting: on the shared PostgreSQL database a default + // left behind would pull other tests' new users into it. + for id in [a.id, b.id, c.id] { + AccessGroupRepository::delete(db, id).await.unwrap(); + } + + assert!(second.is_err(), "a second default group was accepted"); +} + +#[tokio::test] +async fn test_single_default_group_sqlite() { + let (db, _t) = setup_test_db().await; + exercise_single_default(&db).await; +} + +#[tokio::test] +#[ignore] // Requires PostgreSQL test database +async fn test_single_default_group_postgres() { + let Some(db) = setup_test_db_postgres().await else { + eprintln!("PostgreSQL test database not available, skipping"); + return; + }; + exercise_single_default(&db).await; +} diff --git a/tests/db/mod.rs b/tests/db/mod.rs index 1546c88dc..79100e2fd 100644 --- a/tests/db/mod.rs +++ b/tests/db/mod.rs @@ -2,6 +2,7 @@ // // Tests repositories, authentication utilities, and database operations. +mod access_groups; mod auth; mod book_duplicates; mod collections; diff --git a/tests/db/reading_history_retention.rs b/tests/db/reading_history_retention.rs index 443ef7a8e..10f9db41c 100644 --- a/tests/db/reading_history_retention.rs +++ b/tests/db/reading_history_retention.rs @@ -23,7 +23,7 @@ use codex::db::entities::{books, read_completions, reading_sessions, users}; use codex::db::repositories::{ BookRepository, LibraryRepository, NewSession, ReadCompletionRepository, ReadProgressRepository, ReadingStatsRepository, SeriesRepository, StatsGranularity, StatsSort, - StatsWindow, UserRepository, + StatsWindow, }; use common::*; use migration::{Migrator, MigratorTrait}; @@ -39,9 +39,14 @@ fn unique() -> String { } async fn persist_user(db: &DatabaseConnection) -> Uuid { + use sea_orm::{ActiveModelTrait, IntoActiveModel}; + let handle = format!("reader-{}", unique()); let model = create_test_user(&handle, &format!("{handle}@test.test"), "hash", true); - UserRepository::create(db, &model).await.unwrap().id + // A plain row insert, not `UserRepository::create`: some scenarios run + // against the schema as it was before later migrations, and account + // creation also joins the default access group, which that schema lacks. + model.into_active_model().insert(db).await.unwrap().id } async fn persist_book(db: &DatabaseConnection) -> Uuid { diff --git a/web/openapi.json b/web/openapi.json index 0995dcbc4..1ca58e346 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -231,6 +231,54 @@ ] } }, + "/api/v1/access-groups/{group_id}/add-all-users": { + "post": { + "tags": [ + "Access Groups" + ], + "summary": "Add every user who is not yet a member to an access group (admin only)", + "description": "Meant for a group that has just become the default, since the default only\napplies to accounts created afterwards. Existing memberships keep their\nsource; new ones are recorded as `default`.", + "operationId": "add_all_users_to_access_group", + "parameters": [ + { + "name": "group_id", + "in": "path", + "description": "Access group ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Users added", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddAllUsersResponse" + } + } + } + }, + "403": { + "description": "Forbidden - Missing permission" + }, + "404": { + "description": "Access group not found" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/access-groups/{group_id}/grants": { "post": { "tags": [ @@ -555,6 +603,112 @@ ] } }, + "/api/v1/access-groups/{group_id}/rules": { + "get": { + "tags": [ + "Access Groups" + ], + "summary": "Get every rule of an access group (admin only)", + "operationId": "get_access_group_rules", + "parameters": [ + { + "name": "group_id", + "in": "path", + "description": "Access group ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The group's rules", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessGroupRulesDto" + } + } + } + }, + "403": { + "description": "Forbidden - Missing permission" + }, + "404": { + "description": "Access group not found" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + }, + "put": { + "tags": [ + "Access Groups" + ], + "summary": "Replace every rule of an access group (admin only)", + "description": "The whole rule set is replaced atomically: anything not in the request is\nremoved. Sharing tag and library IDs must exist; tag and genre names need\nnot, so a rule can be set up before any series carries the name.", + "operationId": "replace_access_group_rules", + "parameters": [ + { + "name": "group_id", + "in": "path", + "description": "Access group ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceAccessGroupRulesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Rules replaced", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessGroupRulesDto" + } + } + } + }, + "400": { + "description": "Duplicate rule, blank name, or unknown sharing tag or library" + }, + "403": { + "description": "Forbidden - Missing permission" + }, + "404": { + "description": "Access group not found" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/admin/cleanup-orphans": { "post": { "tags": [ @@ -9691,7 +9845,7 @@ { "name": "exact", "in": "query", - "description": "Count pages exactly instead of estimating. Admin only: on PostgreSQL an\nexact count is a full scan of the largest table.", + "description": "Count pages exactly instead of estimating. On PostgreSQL an exact count\nis a full scan of the largest table.", "required": false, "schema": { "type": "boolean" @@ -9710,7 +9864,7 @@ } }, "403": { - "description": "Permission denied, or `exact=true` requested without admin access" + "description": "Requires tasks:read (admins and maintainers)" } }, "security": [ @@ -9743,7 +9897,7 @@ } }, "403": { - "description": "Permission denied" + "description": "Requires tasks:read (admins and maintainers)" } }, "security": [ @@ -9776,7 +9930,7 @@ } }, "403": { - "description": "Permission denied" + "description": "Requires tasks:read (admins and maintainers)" }, "503": { "description": "Task metrics service not available" @@ -9924,7 +10078,7 @@ } }, "403": { - "description": "Permission denied" + "description": "Requires tasks:read (admins and maintainers)" }, "503": { "description": "Task metrics service not available" @@ -19823,6 +19977,67 @@ ] } }, + "/api/v1/users/{user_id}/visibility-preview": { + "get": { + "tags": [ + "Access Groups" + ], + "summary": "Preview what a user can see (admin only)", + "description": "Counts the series the user can and cannot see and lists every library with\nwhether they can see it. With `seriesId`, also explains that series: which\ndeny rules hide it (and from which group), or which groups let it through,\nor that no allow rule matches it. Uses the same rules as enforcement, built\nfresh rather than from the cache.", + "operationId": "get_visibility_preview", + "parameters": [ + { + "name": "user_id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "seriesId", + "in": "query", + "description": "Explain whether the user can see this series", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "What the user can see", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VisibilityPreviewDto" + } + } + } + }, + "403": { + "description": "Forbidden - Missing permission" + }, + "404": { + "description": "User or series not found" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/want-to-read": { "get": { "tags": [ @@ -23536,12 +23751,24 @@ }, "components": { "schemas": { + "AccessDecision": { + "type": "string", + "description": "The outcome for one series, and the reason for it.", + "enum": [ + "unrestricted", + "open", + "allowed", + "notAllowed", + "denied" + ] + }, "AccessGroupDetailDto": { "type": "object", "description": "Access group detail (includes members, grants, and OIDC mappings)", "required": [ "id", "name", + "isDefault", "members", "grants", "oidcMappings", @@ -23576,6 +23803,11 @@ "description": "Unique access group identifier", "example": "550e8400-e29b-41d4-a716-446655440000" }, + "isDefault": { + "type": "boolean", + "description": "Whether new users join this group when their account is created", + "example": false + }, "members": { "type": "array", "items": { @@ -23609,8 +23841,10 @@ "required": [ "id", "name", + "isDefault", "memberCount", "grantCount", + "ruleCount", "createdAt", "updatedAt" ], @@ -23632,7 +23866,7 @@ "grantCount": { "type": "integer", "format": "int64", - "description": "Number of tag grants in the group", + "description": "Number of sharing tag grants in the group", "example": 3, "minimum": 0 }, @@ -23642,6 +23876,11 @@ "description": "Unique access group identifier", "example": "550e8400-e29b-41d4-a716-446655440000" }, + "isDefault": { + "type": "boolean", + "description": "Whether new users join this group when their account is created", + "example": false + }, "memberCount": { "type": "integer", "format": "int64", @@ -23654,6 +23893,13 @@ "description": "Display name of the access group", "example": "Manga Readers" }, + "ruleCount": { + "type": "integer", + "format": "int64", + "description": "Number of rules in the group, across sharing tags, tags, genres and libraries", + "example": 5, + "minimum": 0 + }, "updatedAt": { "type": "string", "format": "date-time", @@ -23757,6 +24003,61 @@ } } }, + "AccessGroupRulesDto": { + "type": "object", + "description": "Every rule of an access group\n\nWithin a group, values of the same type combine as \"any of\" or \"all of\"\n(per the match field for that type; libraries are always \"any of\"), and\ndifferent types combine with AND. Across groups, what each group allows is\ncombined with OR. Denies from every group always win. Admins and\nmaintainers are never restricted.", + "required": [ + "sharingTagMatch", + "tagMatch", + "genreMatch", + "sharingTags", + "tags", + "genres", + "libraries" + ], + "properties": { + "genreMatch": { + "$ref": "#/components/schemas/RuleMatch", + "description": "How allowed genres combine" + }, + "genres": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameRuleDto" + }, + "description": "Rules on genres, matched by name" + }, + "libraries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryRuleDto" + }, + "description": "Rules on libraries" + }, + "sharingTagMatch": { + "$ref": "#/components/schemas/RuleMatch", + "description": "How allowed sharing tags combine" + }, + "sharingTags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SharingTagRuleDto" + }, + "description": "Rules on sharing tags" + }, + "tagMatch": { + "$ref": "#/components/schemas/RuleMatch", + "description": "How allowed tags combine" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameRuleDto" + }, + "description": "Rules on regular tags, matched by name" + } + } + }, "AccessGroupSummaryDto": { "type": "object", "description": "Access group summary (for user's group list)", @@ -23848,6 +24149,22 @@ } } }, + "AddAllUsersResponse": { + "type": "object", + "description": "Result of adding every user to an access group", + "required": [ + "added" + ], + "properties": { + "added": { + "type": "integer", + "format": "int64", + "description": "Users who were not members yet and have been added", + "example": 12, + "minimum": 0 + } + } + }, "AddBookGenreRequest": { "type": "object", "description": "Request to add a single genre to a book", @@ -34921,6 +35238,71 @@ } } }, + "LibraryRuleDto": { + "type": "object", + "description": "A rule on a library", + "required": [ + "libraryId", + "name", + "accessMode" + ], + "properties": { + "accessMode": { + "$ref": "#/components/schemas/AccessMode", + "description": "Allow or deny" + }, + "libraryId": { + "type": "string", + "format": "uuid", + "description": "Library ID" + }, + "name": { + "type": "string", + "description": "Library name", + "example": "Manga" + } + } + }, + "LibraryRuleInput": { + "type": "object", + "description": "A library rule in a replace request", + "required": [ + "libraryId", + "accessMode" + ], + "properties": { + "accessMode": { + "$ref": "#/components/schemas/AccessMode", + "description": "Allow or deny" + }, + "libraryId": { + "type": "string", + "format": "uuid", + "description": "Library ID" + } + } + }, + "LibraryVisibilityDto": { + "type": "object", + "description": "A library and whether the previewed user can see it", + "required": [ + "id", + "name", + "visible" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "visible": { + "type": "boolean" + } + } + }, "LinkProperties": { "type": "object", "description": "Additional properties that can be attached to links", @@ -35161,12 +35543,34 @@ } } }, + "MatchedRuleDto": { + "type": "object", + "description": "A deny rule that matches a series", + "required": [ + "ruleType", + "value", + "source" + ], + "properties": { + "ruleType": { + "$ref": "#/components/schemas/RuleTypeLabel" + }, + "source": { + "$ref": "#/components/schemas/GrantSourceDto" + }, + "value": { + "type": "string", + "description": "The tag or genre name, or the sharing tag or library name" + } + } + }, "MembershipSource": { "type": "string", "description": "Provenance of a group membership", "enum": [ "manual", - "oidc" + "oidc", + "default" ] }, "MessageResponse": { @@ -35959,6 +36363,25 @@ } } }, + "NameRuleDto": { + "type": "object", + "description": "A rule on a regular tag or a genre\n\nMatched by name, case-insensitively, so the rule keeps applying when the\ntag or genre is removed by cleanup and created again later.", + "required": [ + "name", + "accessMode" + ], + "properties": { + "accessMode": { + "$ref": "#/components/schemas/AccessMode", + "description": "Allow or deny" + }, + "name": { + "type": "string", + "description": "Tag or genre name", + "example": "Adult" + } + } + }, "NewsEntryDto": { "type": "object", "description": "One news entry.", @@ -41495,6 +41918,71 @@ } } }, + "ReplaceAccessGroupRulesRequest": { + "type": "object", + "description": "Replace every rule of an access group\n\nOmitted lists are treated as empty and omitted match fields as \"any\".", + "properties": { + "genreMatch": { + "oneOf": [ + { + "$ref": "#/components/schemas/RuleMatch", + "description": "How allowed genres combine" + } + ], + "default": "any" + }, + "genres": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameRuleDto" + }, + "description": "Rules on genres, matched by name", + "default": [] + }, + "libraries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryRuleInput" + }, + "description": "Rules on libraries", + "default": [] + }, + "sharingTagMatch": { + "oneOf": [ + { + "$ref": "#/components/schemas/RuleMatch", + "description": "How allowed sharing tags combine" + } + ], + "default": "any" + }, + "sharingTags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SharingTagRuleInput" + }, + "description": "Rules on sharing tags", + "default": [] + }, + "tagMatch": { + "oneOf": [ + { + "$ref": "#/components/schemas/RuleMatch", + "description": "How allowed tags combine" + } + ], + "default": "any" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NameRuleDto" + }, + "description": "Rules on regular tags, matched by name", + "default": [] + } + } + }, "ReplaceBookMetadataRequest": { "type": "object", "description": "PUT request for full replacement of book metadata\n\nAll metadata fields will be replaced with the values in this request.\nOmitting a field (or setting it to null) will clear that field.", @@ -42210,6 +42698,24 @@ } } }, + "RuleMatch": { + "type": "string", + "description": "How a group's allowed values of one rule type combine.", + "enum": [ + "any", + "all" + ] + }, + "RuleTypeLabel": { + "type": "string", + "description": "The kind of value a rule names.", + "enum": [ + "sharingTag", + "tag", + "genre", + "library" + ] + }, "RunNowResponse": { "type": "object", "description": "Response for `POST .../run-now`.", @@ -42472,6 +42978,47 @@ } } }, + "SeriesAccessDto": { + "type": "object", + "description": "Whether the previewed user can see one series, and why", + "required": [ + "seriesId", + "title", + "visible", + "decision", + "deniedBy", + "allowedBy" + ], + "properties": { + "allowedBy": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GrantSourceDto" + }, + "description": "Every group (or personal grant) whose allow rules let it through" + }, + "decision": { + "$ref": "#/components/schemas/AccessDecision" + }, + "deniedBy": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MatchedRuleDto" + }, + "description": "Every deny rule that matches this series" + }, + "seriesId": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "visible": { + "type": "boolean" + } + } + }, "SeriesAliasDto": { "type": "object", "description": "Title alias used by release-source plugins to match incoming releases by\ntitle (Nyaa, MangaUpdates without an external ID, etc.).", @@ -44553,6 +45100,50 @@ } } }, + "SharingTagRuleDto": { + "type": "object", + "description": "A rule on a sharing tag", + "required": [ + "sharingTagId", + "name", + "accessMode" + ], + "properties": { + "accessMode": { + "$ref": "#/components/schemas/AccessMode", + "description": "Allow or deny" + }, + "name": { + "type": "string", + "description": "Sharing tag name", + "example": "Kids" + }, + "sharingTagId": { + "type": "string", + "format": "uuid", + "description": "Sharing tag ID" + } + } + }, + "SharingTagRuleInput": { + "type": "object", + "description": "A sharing tag rule in a replace request", + "required": [ + "sharingTagId", + "accessMode" + ], + "properties": { + "accessMode": { + "$ref": "#/components/schemas/AccessMode", + "description": "Allow or deny" + }, + "sharingTagId": { + "type": "string", + "format": "uuid", + "description": "Sharing tag ID" + } + } + }, "SharingTagSummaryDto": { "type": "object", "description": "Simplified sharing tag for lists (without counts)", @@ -46465,6 +47056,14 @@ "description": "New description (set to null to remove)", "example": "Updated description" }, + "isDefault": { + "type": [ + "boolean", + "null" + ], + "description": "Make this the default group (clearing any other default), or stop it\nbeing the default. Only affects accounts created afterwards.", + "example": true + }, "name": { "type": [ "string", @@ -47543,6 +48142,33 @@ } } }, + "UserAccessGroupDto": { + "type": "object", + "description": "An access group membership as shown on a user", + "required": [ + "id", + "name", + "source" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Access group ID", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "name": { + "type": "string", + "description": "Access group name", + "example": "Kids" + }, + "source": { + "type": "string", + "description": "Membership source (manual or oidc)", + "example": "manual" + } + } + }, "UserDetailDto": { "type": "object", "description": "User detail DTO with sharing tag grants (for single user view)", @@ -47631,9 +48257,17 @@ "permissions", "isActive", "createdAt", - "updatedAt" + "updatedAt", + "accessGroups" ], "properties": { + "accessGroups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserAccessGroupDto" + }, + "description": "Access groups the user belongs to, sorted by name" + }, "createdAt": { "type": "string", "format": "date-time", @@ -48334,6 +48968,56 @@ } } }, + "VisibilityPreviewDto": { + "type": "object", + "description": "What a user can see, for an admin checking an access setup", + "required": [ + "userId", + "restricted", + "roleBypass", + "visibleSeriesCount", + "hiddenSeriesCount", + "libraries" + ], + "properties": { + "hiddenSeriesCount": { + "type": "integer", + "format": "int64", + "description": "Series hidden from the user", + "minimum": 0 + }, + "libraries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryVisibilityDto" + }, + "description": "Every library, and whether the user can see it" + }, + "restricted": { + "type": "boolean", + "description": "False for admins and maintainers, and for users no rule applies to" + }, + "roleBypass": { + "type": "boolean", + "description": "The user's role exempts them from access groups (admin or maintainer)" + }, + "series": { + "$ref": "#/components/schemas/SeriesAccessDto", + "description": "Present when a series was asked about" + }, + "userId": { + "type": "string", + "format": "uuid", + "description": "The previewed user" + }, + "visibleSeriesCount": { + "type": "integer", + "format": "int64", + "description": "Series the user can see", + "minimum": 0 + } + } + }, "WantToReadEntryDto": { "type": "object", "description": "A single entry in a user's want-to-read queue. Exactly one of `series_id` /\n`book_id` is populated, matching `item_type`.", diff --git a/web/src/App.tsx b/web/src/App.tsx index 50b9efd1d..f636f22d7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,6 +11,7 @@ import { } from "react-router-dom"; import { onRateLimitNotification } from "@/api/client"; import { setupApi } from "@/api/setup"; +import { RequirePermission } from "@/components/auth/RequirePermission"; import { AppLayout } from "@/components/layout/AppLayout"; import { useEntityEvents } from "@/hooks/useEntityEvents"; import { BookDetail } from "@/pages/BookDetail"; @@ -56,6 +57,7 @@ import { WantToRead } from "@/pages/WantToRead"; import { navigationService } from "@/services/navigation"; import { useAuthStore } from "@/store/authStore"; import { useBulkSelectionStore } from "@/store/bulkSelectionStore"; +import { PERMISSIONS } from "@/types/permissions"; import { migrateSeriesReadingDirections } from "@/utils/readingDirectionMigration"; // Protected route wrapper @@ -545,9 +547,11 @@ function App() { path="/settings/metrics" element={ - - - + + + + + } /> diff --git a/web/src/api/accessGroups.ts b/web/src/api/accessGroups.ts index 979610896..04080bbe0 100644 --- a/web/src/api/accessGroups.ts +++ b/web/src/api/accessGroups.ts @@ -26,6 +26,13 @@ export type EffectiveGrantsResponse = components["schemas"]["EffectiveGrantsResponse"]; export type EffectiveGrantDto = components["schemas"]["EffectiveGrantDto"]; export type GrantSourceDto = components["schemas"]["GrantSourceDto"]; +export type AddAllUsersResponse = components["schemas"]["AddAllUsersResponse"]; +export type AccessGroupRulesDto = components["schemas"]["AccessGroupRulesDto"]; +export type ReplaceAccessGroupRulesRequest = + components["schemas"]["ReplaceAccessGroupRulesRequest"]; +export type RuleMatch = components["schemas"]["RuleMatch"]; +export type VisibilityPreviewDto = + components["schemas"]["VisibilityPreviewDto"]; export const accessGroupsApi = { // ============================================ @@ -85,6 +92,37 @@ export const accessGroupsApi = { await api.delete(`/access-groups/${groupId}/members/${userId}`); }, + /** Add every user who is not yet a member (existing memberships unchanged). */ + addAllUsers: async (groupId: string): Promise => { + const response = await api.post( + `/access-groups/${groupId}/add-all-users`, + ); + return response.data; + }, + + // ============================================ + // Rules (sharing tags, tags, genres, libraries) + // ============================================ + + getRules: async (groupId: string): Promise => { + const response = await api.get( + `/access-groups/${groupId}/rules`, + ); + return response.data; + }, + + /** Replace the group's whole rule set; anything not included is removed. */ + replaceRules: async ( + groupId: string, + request: ReplaceAccessGroupRulesRequest, + ): Promise => { + const response = await api.put( + `/access-groups/${groupId}/rules`, + request, + ); + return response.data; + }, + // ============================================ // Grants // ============================================ @@ -138,6 +176,18 @@ export const accessGroupsApi = { return response.data; }, + /** What a user can see, and optionally why one series is shown or hidden. */ + getVisibilityPreview: async ( + userId: string, + seriesId?: string, + ): Promise => { + const response = await api.get( + `/users/${userId}/visibility-preview`, + { params: seriesId ? { seriesId } : undefined }, + ); + return response.data; + }, + getEffectiveGrants: async ( userId: string, ): Promise => { diff --git a/web/src/components/access-groups/AccessGroupRulesCard.test.tsx b/web/src/components/access-groups/AccessGroupRulesCard.test.tsx new file mode 100644 index 000000000..55547a792 --- /dev/null +++ b/web/src/components/access-groups/AccessGroupRulesCard.test.tsx @@ -0,0 +1,179 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AccessGroupRulesDto } from "@/api/accessGroups"; +import { accessGroupsApi } from "@/api/accessGroups"; +import { genresApi } from "@/api/genres"; +import { librariesApi } from "@/api/libraries"; +import { sharingTagsApi } from "@/api/sharingTags"; +import { tagsApi } from "@/api/tags"; +import { renderWithProviders, screen, userEvent, waitFor } from "@/test/utils"; +import { AccessGroupRulesCard } from "./AccessGroupRulesCard"; + +vi.mock("@mantine/notifications", () => ({ + notifications: { show: vi.fn() }, +})); + +vi.mock("@/api/accessGroups", () => ({ + accessGroupsApi: { + getRules: vi.fn(), + replaceRules: vi.fn(), + }, +})); +vi.mock("@/api/genres", () => ({ genresApi: { getAll: vi.fn() } })); +vi.mock("@/api/tags", () => ({ tagsApi: { getAll: vi.fn() } })); +vi.mock("@/api/libraries", () => ({ librariesApi: { getAll: vi.fn() } })); +vi.mock("@/api/sharingTags", () => ({ sharingTagsApi: { list: vi.fn() } })); + +const rules = ( + overrides: Partial = {}, +): AccessGroupRulesDto => ({ + sharingTagMatch: "any", + tagMatch: "any", + genreMatch: "any", + sharingTags: [], + tags: [], + genres: [], + libraries: [], + ...overrides, +}); + +describe("AccessGroupRulesCard", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(genresApi.getAll).mockResolvedValue([ + { + id: "g1", + name: "Adult", + seriesCount: 3, + createdAt: "2026-01-01T00:00:00Z", + }, + ] as never); + vi.mocked(tagsApi.getAll).mockResolvedValue([]); + vi.mocked(librariesApi.getAll).mockResolvedValue([ + { id: "lib-kids", name: "Kids" }, + { id: "lib-manga", name: "Manga" }, + ] as never); + vi.mocked(sharingTagsApi.list).mockResolvedValue([]); + vi.mocked(accessGroupsApi.replaceRules).mockImplementation( + async (_id, request) => + rules({ + ...request, + sharingTags: [], + libraries: (request.libraries ?? []).map((l) => ({ + ...l, + name: l.libraryId, + })), + } as never), + ); + }); + + it("shows the existing rules and that admins are not restricted", async () => { + vi.mocked(accessGroupsApi.getRules).mockResolvedValue( + rules({ + genres: [{ name: "Adult", accessMode: "deny" }], + libraries: [ + { libraryId: "lib-kids", name: "Kids", accessMode: "allow" }, + ], + }), + ); + renderWithProviders(); + + expect(await screen.findByText("Adult")).toBeInTheDocument(); + expect(screen.getByText("Kids")).toBeInTheDocument(); + expect( + screen.getByText(/admins and maintainers are never restricted/i), + ).toBeInTheDocument(); + }); + + it("adds a genre deny on top of the existing rules", async () => { + const user = userEvent.setup(); + vi.mocked(accessGroupsApi.getRules).mockResolvedValue( + rules({ + libraries: [ + { libraryId: "lib-kids", name: "Kids", accessMode: "allow" }, + ], + }), + ); + renderWithProviders(); + + const input = await screen.findByRole("textbox", { name: /add genre/i }); + await user.type(input, "Adult"); + await user.click(screen.getByRole("button", { name: /deny genre/i })); + + await waitFor(() => + expect(accessGroupsApi.replaceRules).toHaveBeenCalledWith( + "group-1", + expect.objectContaining({ + genres: [{ name: "Adult", accessMode: "deny" }], + libraries: [{ libraryId: "lib-kids", accessMode: "allow" }], + }), + ), + ); + }); + + it("removes a rule", async () => { + const user = userEvent.setup(); + vi.mocked(accessGroupsApi.getRules).mockResolvedValue( + rules({ + genres: [ + { name: "Adult", accessMode: "deny" }, + { name: "Horror", accessMode: "deny" }, + ], + }), + ); + renderWithProviders(); + + await user.click( + await screen.findByRole("button", { name: "Remove genre Adult" }), + ); + + await waitFor(() => + expect(accessGroupsApi.replaceRules).toHaveBeenCalledWith( + "group-1", + expect.objectContaining({ + genres: [{ name: "Horror", accessMode: "deny" }], + }), + ), + ); + }); + + it("switches how allowed genres combine", async () => { + const user = userEvent.setup(); + vi.mocked(accessGroupsApi.getRules).mockResolvedValue( + rules({ + genres: [ + { name: "Kids", accessMode: "allow" }, + { name: "Comedy", accessMode: "allow" }, + ], + }), + ); + renderWithProviders(); + + await user.click( + await screen.findByRole("radio", { name: /all allowed genres/i }), + ); + + await waitFor(() => + expect(accessGroupsApi.replaceRules).toHaveBeenCalledWith( + "group-1", + expect.objectContaining({ genreMatch: "all" }), + ), + ); + }); + + it("offers no match switch for libraries", async () => { + vi.mocked(accessGroupsApi.getRules).mockResolvedValue( + rules({ + libraries: [ + { libraryId: "lib-kids", name: "Kids", accessMode: "allow" }, + { libraryId: "lib-manga", name: "Manga", accessMode: "allow" }, + ], + }), + ); + renderWithProviders(); + + await screen.findByText("Manga"); + expect( + screen.queryByRole("radio", { name: /all allowed libraries/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/access-groups/AccessGroupRulesCard.tsx b/web/src/components/access-groups/AccessGroupRulesCard.tsx new file mode 100644 index 000000000..e5b5eec54 --- /dev/null +++ b/web/src/components/access-groups/AccessGroupRulesCard.tsx @@ -0,0 +1,425 @@ +import { + ActionIcon, + Alert, + Autocomplete, + Badge, + Button, + Card, + Divider, + Group, + Loader, + SegmentedControl, + Select, + Stack, + Text, + Title, + Tooltip, +} from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { + IconFilter, + IconInfoCircle, + IconShieldOff, + IconTrash, +} from "@tabler/icons-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { + type AccessGroupRulesDto, + accessGroupsApi, + type ReplaceAccessGroupRulesRequest, + type RuleMatch, +} from "@/api/accessGroups"; +import { genresApi } from "@/api/genres"; +import { librariesApi } from "@/api/libraries"; +import { type AccessMode, sharingTagsApi } from "@/api/sharingTags"; +import { tagsApi } from "@/api/tags"; + +interface AccessGroupRulesCardProps { + groupId: string; +} + +/** Strip display-only fields so a rule set can be sent back as a replace request. */ +function toRequest(rules: AccessGroupRulesDto): ReplaceAccessGroupRulesRequest { + return { + sharingTagMatch: rules.sharingTagMatch, + tagMatch: rules.tagMatch, + genreMatch: rules.genreMatch, + sharingTags: rules.sharingTags.map(({ sharingTagId, accessMode }) => ({ + sharingTagId, + accessMode, + })), + tags: rules.tags.map(({ name, accessMode }) => ({ name, accessMode })), + genres: rules.genres.map(({ name, accessMode }) => ({ name, accessMode })), + libraries: rules.libraries.map(({ libraryId, accessMode }) => ({ + libraryId, + accessMode, + })), + }; +} + +/** + * Editor for every rule of an access group: libraries, genres, tags and + * sharing tags, each allow or deny. Every change replaces the whole rule set + * on the server, so what is shown is always what is stored. + */ +export function AccessGroupRulesCard({ groupId }: AccessGroupRulesCardProps) { + const queryClient = useQueryClient(); + const queryKey = ["access-group-rules", groupId]; + + const { data: rules, isLoading } = useQuery({ + queryKey, + queryFn: () => accessGroupsApi.getRules(groupId), + }); + const { data: libraries = [] } = useQuery({ + queryKey: ["libraries"], + queryFn: librariesApi.getAll, + }); + const { data: genres = [] } = useQuery({ + queryKey: ["genres"], + queryFn: genresApi.getAll, + }); + const { data: tags = [] } = useQuery({ + queryKey: ["tags"], + queryFn: tagsApi.getAll, + }); + const { data: sharingTags = [] } = useQuery({ + queryKey: ["sharing-tags"], + queryFn: sharingTagsApi.list, + }); + + const saveMutation = useMutation({ + mutationFn: (request: ReplaceAccessGroupRulesRequest) => + accessGroupsApi.replaceRules(groupId, request), + onSuccess: (saved) => { + queryClient.setQueryData(queryKey, saved); + queryClient.invalidateQueries({ queryKey: ["access-group", groupId] }); + queryClient.invalidateQueries({ queryKey: ["access-groups"] }); + }, + onError: (error: { message?: string }) => { + notifications.show({ + title: "Error", + message: error.message || "Failed to save rules", + color: "red", + }); + }, + }); + + if (isLoading || !rules) { + return ( + + + + + + ); + } + + const save = (change: Partial) => + saveMutation.mutate({ ...toRequest(rules), ...change }); + const current = toRequest(rules); + + const usedLibraries = new Set(rules.libraries.map((r) => r.libraryId)); + const usedSharingTags = new Set(rules.sharingTags.map((r) => r.sharingTagId)); + const usedGenres = new Set(rules.genres.map((r) => r.name.toLowerCase())); + const usedTags = new Set(rules.tags.map((r) => r.name.toLowerCase())); + + return ( + + + + + Rules + + + + Allow rules narrow what members see: different kinds of rules in this + group must all match (library and genre). A member of several + groups sees what any of their groups allows. Deny rules hide content + everywhere, whatever another group allows. With no allow rules, + members see everything that is not denied. + + + } + p="xs" + > + Admins and maintainers are never restricted by access groups, even + when they are members. + + + ({ + key: r.libraryId, + label: r.name, + accessMode: r.accessMode, + }))} + options={libraries + .filter((l) => !usedLibraries.has(l.id)) + .map((l) => ({ value: l.id, label: l.name }))} + onAdd={(libraryId, accessMode) => + save({ + libraries: [...current.libraries!, { libraryId, accessMode }], + }) + } + onRemove={(libraryId) => + save({ + libraries: current.libraries!.filter( + (r) => r.libraryId !== libraryId, + ), + }) + } + busy={saveMutation.isPending} + /> + + + + ({ + key: r.name, + label: r.name, + accessMode: r.accessMode, + }))} + options={genres + .filter((g) => !usedGenres.has(g.name.toLowerCase())) + .map((g) => ({ value: g.name, label: g.name }))} + match={rules.genreMatch} + onMatchChange={(genreMatch) => save({ genreMatch })} + onAdd={(name, accessMode) => + save({ genres: [...current.genres!, { name, accessMode }] }) + } + onRemove={(name) => + save({ genres: current.genres!.filter((r) => r.name !== name) }) + } + busy={saveMutation.isPending} + /> + + + + ({ + key: r.name, + label: r.name, + accessMode: r.accessMode, + }))} + options={tags + .filter((t) => !usedTags.has(t.name.toLowerCase())) + .map((t) => ({ value: t.name, label: t.name }))} + match={rules.tagMatch} + onMatchChange={(tagMatch) => save({ tagMatch })} + onAdd={(name, accessMode) => + save({ tags: [...current.tags!, { name, accessMode }] }) + } + onRemove={(name) => + save({ tags: current.tags!.filter((r) => r.name !== name) }) + } + busy={saveMutation.isPending} + /> + + + + ({ + key: r.sharingTagId, + label: r.name, + accessMode: r.accessMode, + }))} + options={sharingTags + .filter((t) => !usedSharingTags.has(t.id)) + .map((t) => ({ value: t.id, label: t.name }))} + match={rules.sharingTagMatch} + onMatchChange={(sharingTagMatch) => save({ sharingTagMatch })} + onAdd={(sharingTagId, accessMode) => + save({ + sharingTags: [ + ...current.sharingTags!, + { sharingTagId, accessMode }, + ], + }) + } + onRemove={(sharingTagId) => + save({ + sharingTags: current.sharingTags!.filter( + (r) => r.sharingTagId !== sharingTagId, + ), + }) + } + busy={saveMutation.isPending} + /> + + + ); +} + +// ==================== One rule type ==================== + +interface RuleRow { + key: string; + label: string; + accessMode: AccessMode; +} + +interface RuleSectionProps { + singular: string; + plural: string; + rules: RuleRow[]; + options: { value: string; label: string }[]; + /** Tags and genres accept any name, not only existing ones */ + freeText?: boolean; + hint?: string; + /** Omitted for types where "all of" cannot apply (libraries) */ + match?: RuleMatch; + onMatchChange?: (match: RuleMatch) => void; + onAdd: (value: string, accessMode: AccessMode) => void; + onRemove: (key: string) => void; + busy: boolean; +} + +function RuleSection({ + singular, + plural, + rules, + options, + freeText = false, + hint, + match, + onMatchChange, + onAdd, + onRemove, + busy, +}: RuleSectionProps) { + const [value, setValue] = useState(null); + const capitalized = plural.charAt(0).toUpperCase() + plural.slice(1); + const allowCount = rules.filter((r) => r.accessMode === "allow").length; + + const add = (accessMode: AccessMode) => { + const trimmed = value?.trim(); + if (!trimmed) return; + onAdd(trimmed, accessMode); + setValue(null); + }; + + return ( + + + + {capitalized} + + {rules.length} + + + {match && onMatchChange && allowCount >= 2 && ( + onMatchChange(v as RuleMatch)} + disabled={busy} + data={[ + { value: "any", label: `Any allowed ${singular}` }, + { value: "all", label: `All allowed ${plural}` }, + ]} + /> + )} + + + {hint && ( + + + + {hint} + + + )} + + {rules.length > 0 ? ( + + {rules.map((rule) => ( + + + + {rule.accessMode === "deny" ? "Deny" : "Allow"} + + {rule.label} + + + onRemove(rule.key)} + disabled={busy} + > + + + + + ))} + + ) : ( + + No {singular} rules. + + )} + + + {freeText ? ( + + ) : ( + ({ value: s.id, label: s.title }))} + value={seriesId} + onChange={setSeriesId} + filter={({ options }) => options} + nothingFoundMessage={ + debouncedSearch ? "No series found" : undefined + } + clearable + /> + + {explained?.series && ( + + )} + + )} + + + ); +} + +function SeriesExplanation({ access }: { access: SeriesAccess }) { + return ( + + + + {access.visible ? "Visible" : "Hidden"} + + + {access.title} + + + {access.decision === "denied" && ( + <> + Hidden by: + {access.deniedBy.map((rule) => ( + + Deny {RULE_TYPE_LABELS[rule.ruleType]} "{rule.value}", from{" "} + {describeSource(rule.source)} + + ))} + + )} + {access.decision === "notAllowed" && ( + + Hidden: this user's groups only show what their allow rules match, and + none of them matches this series. + + )} + {access.decision === "allowed" && ( + + Shown by {access.allowedBy.map(describeSource).join(", ")}. + + )} + {access.decision === "open" && ( + + Shown: no allow rule applies to this user, and no deny rule matches. + + )} + {access.decision === "unrestricted" && ( + + Shown: this user's role is never restricted. + + )} + + ); +} diff --git a/web/src/components/users/index.ts b/web/src/components/users/index.ts index 414a37a79..95a3ceedc 100644 --- a/web/src/components/users/index.ts +++ b/web/src/components/users/index.ts @@ -1,2 +1,4 @@ +export { UserAccessGroupBadges } from "./UserAccessGroupBadges"; export { UserEffectiveGrants } from "./UserEffectiveGrants"; export { UserSharingTagGrants } from "./UserSharingTagGrants"; +export { UserVisibilityPreview } from "./UserVisibilityPreview"; diff --git a/web/src/mocks/data/factories.ts b/web/src/mocks/data/factories.ts index 5a64a5717..4fd670426 100644 --- a/web/src/mocks/data/factories.ts +++ b/web/src/mocks/data/factories.ts @@ -66,6 +66,7 @@ export const createUser = (overrides: Partial = {}): UserDto => ({ lastLoginAt: faker.date.recent().toISOString(), createdAt: faker.date.past().toISOString(), updatedAt: faker.date.recent().toISOString(), + accessGroups: [], ...overrides, }); diff --git a/web/src/pages/settings/AccessGroupDetail.tsx b/web/src/pages/settings/AccessGroupDetail.tsx index dc29f5959..860f70355 100644 --- a/web/src/pages/settings/AccessGroupDetail.tsx +++ b/web/src/pages/settings/AccessGroupDetail.tsx @@ -20,7 +20,6 @@ import { IconArrowLeft, IconLink, IconPlus, - IconShare, IconShieldCheck, IconTrash, IconUsers, @@ -29,8 +28,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { Link, useParams } from "react-router-dom"; import { type AccessGroupDetailDto, accessGroupsApi } from "@/api/accessGroups"; -import { type AccessMode, sharingTagsApi } from "@/api/sharingTags"; import { usersApi } from "@/api/users"; +import { AccessGroupRulesCard } from "@/components/access-groups/AccessGroupRulesCard"; +import { DefaultGroupCard } from "@/components/access-groups/DefaultGroupCard"; export function AccessGroupDetail() { const { groupId } = useParams<{ groupId: string }>(); @@ -86,6 +86,11 @@ export function AccessGroupDetail() { {group.name} + {group.isDefault && ( + + Default + + )} {group.description && ( @@ -95,6 +100,8 @@ export function AccessGroupDetail() { + + {/* Members Section */} - {/* Grants Section */} - + {/* OIDC Mappings Section */} )} + {member.source === "default" && ( + + Default + + )} ; -}) { - const [selectedTagId, setSelectedTagId] = useState(null); - const [selectedAccessMode, setSelectedAccessMode] = - useState("allow"); - - const { data: allTags } = useQuery({ - queryKey: ["sharing-tags"], - queryFn: sharingTagsApi.list, - }); - - const addGrantMutation = useMutation({ - mutationFn: ({ - tagId, - accessMode, - }: { - tagId: string; - accessMode: AccessMode; - }) => accessGroupsApi.addGrant(groupId, tagId, accessMode), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["access-group", groupId] }); - queryClient.invalidateQueries({ queryKey: ["access-groups"] }); - setSelectedTagId(null); - notifications.show({ - title: "Success", - message: "Grant added", - color: "green", - }); - }, - onError: (error: { message?: string }) => { - notifications.show({ - title: "Error", - message: error.message || "Failed to add grant", - color: "red", - }); - }, - }); - - const removeGrantMutation = useMutation({ - mutationFn: (tagId: string) => accessGroupsApi.removeGrant(groupId, tagId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["access-group", groupId] }); - queryClient.invalidateQueries({ queryKey: ["access-groups"] }); - notifications.show({ - title: "Success", - message: "Grant removed", - color: "green", - }); - }, - onError: (error: { message?: string }) => { - notifications.show({ - title: "Error", - message: error.message || "Failed to remove grant", - color: "red", - }); - }, - }); - - const grantTagIds = new Set(group.grants.map((g) => g.sharingTagId)); - const availableTags = - allTags?.filter((tag) => !grantTagIds.has(tag.id)) || []; - const tagOptions = availableTags.map((tag) => ({ - value: tag.id, - label: tag.name, - })); - - const accessModeOptions = [ - { value: "allow", label: "Allow" }, - { value: "deny", label: "Deny" }, - ]; - - return ( - - - - - Tag Grants - - {group.grants.length} - - - - - Members of this group inherit these sharing-tag grants. "Deny" always - wins over "Allow" across all sources. - - - {group.grants.length > 0 ? ( - - {group.grants.map((grant) => ( - - - - {grant.accessMode === "deny" ? "Deny" : "Allow"} - - {grant.sharingTagName} - - - - removeGrantMutation.mutate(grant.sharingTagId) - } - disabled={removeGrantMutation.isPending} - > - - - - - ))} - - ) : ( - - No grants configured. - - )} - - {tagOptions.length > 0 && ( - - - setSelectedAccessMode((value as AccessMode) || "allow") - } - w={100} - /> - - - selectedTagId && - addGrantMutation.mutate({ - tagId: selectedTagId, - accessMode: selectedAccessMode, - }) - } - disabled={!selectedTagId || addGrantMutation.isPending} - loading={addGrantMutation.isPending} - > - - - - - )} - - - ); -} - // ==================== OIDC Mappings Section ==================== function OidcMappingsSection({ diff --git a/web/src/pages/settings/AccessGroupsSettings.tsx b/web/src/pages/settings/AccessGroupsSettings.tsx index fb0b9e089..301df7799 100644 --- a/web/src/pages/settings/AccessGroupsSettings.tsx +++ b/web/src/pages/settings/AccessGroupsSettings.tsx @@ -178,6 +178,11 @@ export function AccessGroupsSettings() { > {group.name} + {group.isDefault && ( + + Default + + )} ), }, diff --git a/web/src/pages/settings/UsersSettings.tsx b/web/src/pages/settings/UsersSettings.tsx index 5043cb317..6ca493fec 100644 --- a/web/src/pages/settings/UsersSettings.tsx +++ b/web/src/pages/settings/UsersSettings.tsx @@ -41,7 +41,12 @@ import { type UserDto, type UserListParams, usersApi } from "@/api/users"; import { PermissionPicker } from "@/components/common"; import { TableSkeleton } from "@/components/skeletons"; import { ResponsiveTable, type ResponsiveTableColumn } from "@/components/ui"; -import { UserEffectiveGrants, UserSharingTagGrants } from "@/components/users"; +import { + UserAccessGroupBadges, + UserEffectiveGrants, + UserSharingTagGrants, + UserVisibilityPreview, +} from "@/components/users"; import { useShowSkeleton } from "@/lib/motion/useShowSkeleton"; import { useAuthStore } from "@/store/authStore"; import { type Permission, ROLE_PERMISSIONS } from "@/types/permissions"; @@ -362,6 +367,16 @@ export function UsersSettings() { ), }, + { + key: "accessGroups", + header: "Access Groups", + accessor: (user) => ( + + ), + }, { key: "status", header: "Status", @@ -689,6 +704,7 @@ export function UsersSettings() { {/* Effective Grants (combined user + group view) */} {selectedUser && } + {selectedUser && } {/* Custom Permissions */} {selectedUser && selectedUser.role !== "admin" && ( diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index eafd0369a..b551310fb 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -41,6 +41,28 @@ export interface paths { patch: operations["update_access_group"]; trace?: never; }; + "/api/v1/access-groups/{group_id}/add-all-users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Add every user who is not yet a member to an access group (admin only) + * @description Meant for a group that has just become the default, since the default only + * applies to accounts created afterwards. Existing memberships keep their + * source; new ones are recorded as `default`. + */ + post: operations["add_all_users_to_access_group"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/access-groups/{group_id}/grants": { parameters: { query?: never; @@ -143,6 +165,29 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/access-groups/{group_id}/rules": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get every rule of an access group (admin only) */ + get: operations["get_access_group_rules"]; + /** + * Replace every rule of an access group (admin only) + * @description The whole rule set is replaced atomically: anything not in the request is + * removed. Sharing tag and library IDs must exist; tag and genre names need + * not, so a rule can be set up before any series carries the name. + */ + put: operations["replace_access_group_rules"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/cleanup-orphans": { parameters: { query?: never; @@ -6602,6 +6647,30 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/users/{user_id}/visibility-preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Preview what a user can see (admin only) + * @description Counts the series the user can and cannot see and lists every library with + * whether they can see it. With `seriesId`, also explains that series: which + * deny rules hide it (and from which group), or which groups let it through, + * or that no allow rule matches it. Uses the same rules as enforcement, built + * fresh rather than from the cache. + */ + get: operations["get_visibility_preview"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/want-to-read": { parameters: { query?: never; @@ -8292,6 +8361,11 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** + * @description The outcome for one series, and the reason for it. + * @enum {string} + */ + AccessDecision: "unrestricted" | "open" | "allowed" | "notAllowed" | "denied"; /** @description Access group detail (includes members, grants, and OIDC mappings) */ AccessGroupDetailDto: { /** @@ -8313,6 +8387,11 @@ export interface components { * @example 550e8400-e29b-41d4-a716-446655440000 */ id: string; + /** + * @description Whether new users join this group when their account is created + * @example false + */ + isDefault: boolean; /** @description Members of the group */ members: components["schemas"]["AccessGroupMemberDto"][]; /** @@ -8344,7 +8423,7 @@ export interface components { description?: string | null; /** * Format: int64 - * @description Number of tag grants in the group + * @description Number of sharing tag grants in the group * @example 3 */ grantCount: number; @@ -8354,6 +8433,11 @@ export interface components { * @example 550e8400-e29b-41d4-a716-446655440000 */ id: string; + /** + * @description Whether new users join this group when their account is created + * @example false + */ + isDefault: boolean; /** * Format: int64 * @description Number of members in the group @@ -8365,6 +8449,12 @@ export interface components { * @example Manga Readers */ name: string; + /** + * Format: int64 + * @description Number of rules in the group, across sharing tags, tags, genres and libraries + * @example 5 + */ + ruleCount: number; /** * Format: date-time * @description Last update timestamp @@ -8439,6 +8529,31 @@ export interface components { */ oidcGroupName: string; }; + /** + * @description Every rule of an access group + * + * Within a group, values of the same type combine as "any of" or "all of" + * (per the match field for that type; libraries are always "any of"), and + * different types combine with AND. Across groups, what each group allows is + * combined with OR. Denies from every group always win. Admins and + * maintainers are never restricted. + */ + AccessGroupRulesDto: { + /** @description How allowed genres combine */ + genreMatch: components["schemas"]["RuleMatch"]; + /** @description Rules on genres, matched by name */ + genres: components["schemas"]["NameRuleDto"][]; + /** @description Rules on libraries */ + libraries: components["schemas"]["LibraryRuleDto"][]; + /** @description How allowed sharing tags combine */ + sharingTagMatch: components["schemas"]["RuleMatch"]; + /** @description Rules on sharing tags */ + sharingTags: components["schemas"]["SharingTagRuleDto"][]; + /** @description How allowed tags combine */ + tagMatch: components["schemas"]["RuleMatch"]; + /** @description Rules on regular tags, matched by name */ + tags: components["schemas"]["NameRuleDto"][]; + }; /** @description Access group summary (for user's group list) */ AccessGroupSummaryDto: { /** @@ -8492,6 +8607,15 @@ export interface components { */ oidcGroupName: string; }; + /** @description Result of adding every user to an access group */ + AddAllUsersResponse: { + /** + * Format: int64 + * @description Users who were not members yet and have been added + * @example 12 + */ + added: number; + }; /** @description Request to add a single genre to a book */ AddBookGenreRequest: { /** @@ -14542,6 +14666,38 @@ export interface components { */ totalSize: number; }; + /** @description A rule on a library */ + LibraryRuleDto: { + /** @description Allow or deny */ + accessMode: components["schemas"]["AccessMode"]; + /** + * Format: uuid + * @description Library ID + */ + libraryId: string; + /** + * @description Library name + * @example Manga + */ + name: string; + }; + /** @description A library rule in a replace request */ + LibraryRuleInput: { + /** @description Allow or deny */ + accessMode: components["schemas"]["AccessMode"]; + /** + * Format: uuid + * @description Library ID + */ + libraryId: string; + }; + /** @description A library and whether the previewed user can see it */ + LibraryVisibilityDto: { + /** Format: uuid */ + id: string; + name: string; + visible: boolean; + }; /** @description Additional properties that can be attached to links */ LinkProperties: { /** @@ -14678,11 +14834,18 @@ export interface components { */ message: string; }; + /** @description A deny rule that matches a series */ + MatchedRuleDto: { + ruleType: components["schemas"]["RuleTypeLabel"]; + source: components["schemas"]["GrantSourceDto"]; + /** @description The tag or genre name, or the sharing tag or library name */ + value: string; + }; /** * @description Provenance of a group membership * @enum {string} */ - MembershipSource: "manual" | "oidc"; + MembershipSource: "manual" | "oidc" | "default"; MessageResponse: { /** * @description Response message @@ -15140,6 +15303,21 @@ export interface components { */ sharingTagId: string; }; + /** + * @description A rule on a regular tag or a genre + * + * Matched by name, case-insensitively, so the rule keeps applying when the + * tag or genre is removed by cleanup and created again later. + */ + NameRuleDto: { + /** @description Allow or deny */ + accessMode: components["schemas"]["AccessMode"]; + /** + * @description Tag or genre name + * @example Adult + */ + name: string; + }; /** @description One news entry. */ NewsEntryDto: { /** @description Markdown. Render without raw HTML. */ @@ -18322,6 +18500,39 @@ export interface components { */ entryIds: string[]; }; + /** + * @description Replace every rule of an access group + * + * Omitted lists are treated as empty and omitted match fields as "any". + */ + ReplaceAccessGroupRulesRequest: { + /** @default any */ + genreMatch: components["schemas"]["RuleMatch"]; + /** + * @description Rules on genres, matched by name + * @default [] + */ + genres: components["schemas"]["NameRuleDto"][]; + /** + * @description Rules on libraries + * @default [] + */ + libraries: components["schemas"]["LibraryRuleInput"][]; + /** @default any */ + sharingTagMatch: components["schemas"]["RuleMatch"]; + /** + * @description Rules on sharing tags + * @default [] + */ + sharingTags: components["schemas"]["SharingTagRuleInput"][]; + /** @default any */ + tagMatch: components["schemas"]["RuleMatch"]; + /** + * @description Rules on regular tags, matched by name + * @default [] + */ + tags: components["schemas"]["NameRuleDto"][]; + }; /** * @description PUT request for full replacement of book metadata * @@ -18780,6 +18991,16 @@ export interface components { */ tasksEnqueued: number; }; + /** + * @description How a group's allowed values of one rule type combine. + * @enum {string} + */ + RuleMatch: "any" | "all"; + /** + * @description The kind of value a rule names. + * @enum {string} + */ + RuleTypeLabel: "sharingTag" | "tag" | "genre" | "library"; /** @description Response for `POST .../run-now`. */ RunNowResponse: { /** Format: uuid */ @@ -18949,6 +19170,18 @@ export interface components { /** @description Cover source: "default" (first book cover) or "custom" (uploaded cover) */ source: string; }; + /** @description Whether the previewed user can see one series, and why */ + SeriesAccessDto: { + /** @description Every group (or personal grant) whose allow rules let it through */ + allowedBy: components["schemas"]["GrantSourceDto"][]; + decision: components["schemas"]["AccessDecision"]; + /** @description Every deny rule that matches this series */ + deniedBy: components["schemas"]["MatchedRuleDto"][]; + /** Format: uuid */ + seriesId: string; + title: string; + visible: boolean; + }; /** * @description Title alias used by release-source plugins to match incoming releases by * title (Nyaa, MangaUpdates without an external ID, etc.). @@ -20236,6 +20469,31 @@ export interface components { */ total: number; }; + /** @description A rule on a sharing tag */ + SharingTagRuleDto: { + /** @description Allow or deny */ + accessMode: components["schemas"]["AccessMode"]; + /** + * @description Sharing tag name + * @example Kids + */ + name: string; + /** + * Format: uuid + * @description Sharing tag ID + */ + sharingTagId: string; + }; + /** @description A sharing tag rule in a replace request */ + SharingTagRuleInput: { + /** @description Allow or deny */ + accessMode: components["schemas"]["AccessMode"]; + /** + * Format: uuid + * @description Sharing tag ID + */ + sharingTagId: string; + }; /** @description Simplified sharing tag for lists (without counts) */ SharingTagSummaryDto: { /** @@ -21132,6 +21390,12 @@ export interface components { * @example Updated description */ description?: string | null; + /** + * @description Make this the default group (clearing any other default), or stop it + * being the default. Only affects accounts created afterwards. + * @example true + */ + isDefault?: boolean | null; /** * @description New display name (must be unique) * @example Manga & Comic Readers @@ -21677,6 +21941,25 @@ export interface components { */ username?: string | null; }; + /** @description An access group membership as shown on a user */ + UserAccessGroupDto: { + /** + * Format: uuid + * @description Access group ID + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + id: string; + /** + * @description Access group name + * @example Kids + */ + name: string; + /** + * @description Membership source (manual or oidc) + * @example manual + */ + source: string; + }; /** @description User detail DTO with sharing tag grants (for single user view) */ UserDetailDto: { /** @@ -21727,6 +22010,8 @@ export interface components { }; /** @description User data transfer object */ UserDto: { + /** @description Access groups the user belongs to, sorted by name */ + accessGroups: components["schemas"]["UserAccessGroupDto"][]; /** * Format: date-time * @description Account creation timestamp @@ -22130,6 +22415,32 @@ export interface components { /** @description User information */ user: components["schemas"]["UserInfo"]; }; + /** @description What a user can see, for an admin checking an access setup */ + VisibilityPreviewDto: { + /** + * Format: int64 + * @description Series hidden from the user + */ + hiddenSeriesCount: number; + /** @description Every library, and whether the user can see it */ + libraries: components["schemas"]["LibraryVisibilityDto"][]; + /** @description False for admins and maintainers, and for users no rule applies to */ + restricted: boolean; + /** @description The user's role exempts them from access groups (admin or maintainer) */ + roleBypass: boolean; + /** @description Present when a series was asked about */ + series?: components["schemas"]["SeriesAccessDto"]; + /** + * Format: uuid + * @description The previewed user + */ + userId: string; + /** + * Format: int64 + * @description Series the user can see + */ + visibleSeriesCount: number; + }; /** * @description A single entry in a user's want-to-read queue. Exactly one of `series_id` / * `book_id` is populated, matching `item_type`. @@ -22430,6 +22741,43 @@ export interface operations { }; }; }; + add_all_users_to_access_group: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Access group ID */ + group_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Users added */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AddAllUsersResponse"]; + }; + }; + /** @description Forbidden - Missing permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Access group not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; add_access_group_grant: { parameters: { query?: never; @@ -22664,6 +23012,91 @@ export interface operations { }; }; }; + get_access_group_rules: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Access group ID */ + group_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The group's rules */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AccessGroupRulesDto"]; + }; + }; + /** @description Forbidden - Missing permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Access group not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + replace_access_group_rules: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Access group ID */ + group_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReplaceAccessGroupRulesRequest"]; + }; + }; + responses: { + /** @description Rules replaced */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AccessGroupRulesDto"]; + }; + }; + /** @description Duplicate rule, blank name, or unknown sharing tag or library */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden - Missing permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Access group not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; trigger_cleanup: { parameters: { query?: never; @@ -29529,8 +29962,8 @@ export interface operations { parameters: { query?: { /** - * @description Count pages exactly instead of estimating. Admin only: on PostgreSQL an - * exact count is a full scan of the largest table. + * @description Count pages exactly instead of estimating. On PostgreSQL an exact count + * is a full scan of the largest table. */ exact?: boolean; }; @@ -29549,7 +29982,7 @@ export interface operations { "application/json": components["schemas"]["MetricsDto"]; }; }; - /** @description Permission denied, or `exact=true` requested without admin access */ + /** @description Requires tasks:read (admins and maintainers) */ 403: { headers: { [name: string]: unknown; @@ -29576,7 +30009,7 @@ export interface operations { "application/json": components["schemas"]["PluginMetricsResponse"]; }; }; - /** @description Permission denied */ + /** @description Requires tasks:read (admins and maintainers) */ 403: { headers: { [name: string]: unknown; @@ -29603,7 +30036,7 @@ export interface operations { "application/json": components["schemas"]["TaskMetricsResponse"]; }; }; - /** @description Permission denied */ + /** @description Requires tasks:read (admins and maintainers) */ 403: { headers: { [name: string]: unknown; @@ -29721,7 +30154,7 @@ export interface operations { "application/json": components["schemas"]["TaskMetricsHistoryResponse"]; }; }; - /** @description Permission denied */ + /** @description Requires tasks:read (admins and maintainers) */ 403: { headers: { [name: string]: unknown; @@ -37411,6 +37844,46 @@ export interface operations { }; }; }; + get_visibility_preview: { + parameters: { + query?: { + /** @description Explain whether the user can see this series */ + seriesId?: string | null; + }; + header?: never; + path: { + /** @description User ID */ + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description What the user can see */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VisibilityPreviewDto"]; + }; + }; + /** @description Forbidden - Missing permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description User or series not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; list_want_to_read: { parameters: { query?: {