Repository navigation
Access groups: rules on libraries, genres and tags, default group, full filter coverage - #52
Merged
Merged
Conversation
Reconciling a user's OIDC-sourced access group memberships used to log a warning and let the login continue. A first-time OIDC user whose sync failed ended up in no groups, and a user with no groups sees the whole library, so a database hiccup quietly turned into full visibility. The callback now fails the login instead, and the reconcile runs in one transaction so a failure part-way never leaves half the IdP's groups applied. The users list also shows each user's access groups, with the membership source, so an admin can see at a glance who is restricted and spot an account that landed in the wrong group. Memberships for the whole page are fetched in two queries rather than one per user.
A user with no access group sees the whole library, and there was no way to give new accounts a baseline: every kid account had to be added to its group by hand, and forgetting meant full visibility. An access group can now be marked as the default. Every new account joins it, inside the same transaction as the account itself, so registration, admin-created users and first OIDC login all get the baseline and an account can never exist without it. Joining is a one-off at creation, not a rule applied to everyone: the membership is an ordinary row (source `default`) that an admin can remove, and it stays removed. Making a group default does not touch existing users; a separate action adds everyone who is not yet a member, and it never rewrites an existing membership's source, since turning an OIDC membership into a default one would stop the IdP from removing it. At most one group can be the default. Choosing a new one clears the old one, and a partial unique index refuses a second default on both SQLite and PostgreSQL if two requests race.
Adding a user to an access group they were already in rewrote the membership's source. An admin adding someone the IdP had already placed turned the membership into a manual one, which the IdP can never remove, so leaving the IdP group no longer took them out. The reverse happened on login: an IdP group mapping to a group an admin had assigned turned that membership into an OIDC one, and leaving the IdP group then removed what the admin had set up. Whoever adds the user first now owns the membership, and adding them again from another source leaves the row as it is.
CLAUDE.md pointed at `npm run format`, which web/package.json does not define.
Access groups could only allow or deny sharing tags, so hiding adult content from a kids account meant sharing-tagging every series by hand, and a series added later was uncovered until someone tagged it. A group can now allow or deny libraries, genres and regular tags as well as sharing tags. How rules combine: - inside a group, values of one type match as "any of" (switchable to "all of" for genres, tags and sharing tags) and different types must all match, so "library Manga + genre Kids" is Kids manga; - across groups, what each group allows adds up, and personal sharing-tag grants count as one more group; - every deny, from any group or grant, is applied globally and wins, so no other group can grant denied content back. Existing sharing-tag setups resolve exactly as before. Genre and tag rules match by normalized name rather than id. Cleanup deletes genres and tags no series uses, and the next series to carry the name recreates it under a new id; an id-keyed rule would cascade away and silently drop a deny. Admins and maintainers are no longer restricted by groups or grants. Maintainers curate metadata, covers and scans, and every one of those endpoints acts on any id; filtering their browsing while letting them edit anything was inconsistent. An unknown user now sees nothing rather than everything. The filter is built in a fixed number of queries whatever the number of groups. The rule set is replaced atomically through a new rules endpoint, and the admin UI edits all four rule types and says who is exempt. The old visibility helpers on the sharing tag repository are removed: nothing called them, and they encoded older rules (untagged series always visible, groups ignored) that a new caller could have picked up by mistake.
Deploying codex with
|
| Latest commit: |
231ec3f
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://c91d0467.codex-asm.pages.dev |
| Branch Preview URL: | https://feat-access-group-rules.codex-asm.pages.dev |
API contract changesCompared against Report |
Building the content filter takes several queries, and a reader in webtoon mode or a client that prefetches pages fires dozens of requests a second. Each process now keeps the built filter per user, size-capped and evictable at any time; a miss simply rebuilds from the database. A cached filter is used only while it is under 30 seconds old and was built under the current access-control version. Every repository write that changes who can see what (group rules and memberships, sharing-tag grants, series sharing tags, a user's role, deleting a user) bumps that version, so a change made through this process applies on the next request, whatever path made it: the API, the OIDC membership sync on login, or joining the default group at account creation. Another process catches up when its entry expires. Genre and tag changes from scans do not bump it, since scans make them constantly; they apply within the 30 seconds. Handlers now share one set of visibility helpers instead of four copies of the same function, so what a restricted reader can see is decided in one place.
CoverageBackend (Rust)
Frontend (web)
Line-level reports (lcov) are attached to the workflow run as the |
Visibility was enforced only where a handler remembered to check, and most did not. A restricted reader who knew or guessed an id could open a hidden book's pages and cover, download a whole hidden series as a zip, read its metadata, ratings, tracking and releases, and record progress or ratings against it, on the native API, OPDS 1.2 and 2.0, and the Komga API. Membership endpoints also answered differently for a hidden series than for a missing one, so its existence could be probed. Every handler that takes a series or book id now checks visibility first, and a hidden item answers exactly like a missing one (404). The check applies to maintainer endpoints too: maintainers are not filtered, so it costs nothing there, but a reader granted an extra write permission still is. For users with no restrictions the check runs no query at all, which keeps thumbnails that are served from the disk cache off the database. Endpoints that take ids in the body skip hidden ids the way they skip unknown ones: bulk mark read/unread for books and series, bulk add to want-to-read, and reading sessions (rejected as book_not_found, as their documentation already said). The series metadata search title endpoint also had no permission check at all and now requires series read.
Clippy with -D warnings rejects them.
Every surface listed every library to every reader, with series and book counts that included content hidden from them, so a kids account still saw an "Adult" library and how much was in it. Library ids also opened on every library-scoped endpoint whatever the user's rules said. A library is now hidden when a deny rule names it. When a user's rules whitelist content, they see a library only if an allow rule names it or it holds at least one series they can see; with no allow rules, every library that is not denied stays listed. The library lists on the native API, OPDS 1.2, OPDS 2.0 and Komga apply this, library-scoped endpoints answer 404 for a hidden library, and library counts only count what the user can see. The Komga library thumbnail no longer takes its cover from a series the user cannot see.
The Komga series list, series search, new and updated series, and the book search all went through repository queries that had no notion of visibility, so any Komga client (Komic, Paperback, Tachiyomi extensions) listed every series and book in the library to every reader, whatever their access groups said. The series and book query options now carry the user's visibility and apply it to both the page and its total, on every sort path, so a restricted reader's Komga lists match what the native API shows them.
Beyond fetching items by id, several lists still exposed what a reader's access groups hide: - the genre and tag lists (native API and Komga) listed every name, with series counts over the whole library, so a kids account saw "Adult" and how many series carry it; - OPDS 1.2 and 2.0 search returned matching series the reader cannot see (the book half was already filtered); - books in error, the duplicate groups and the release-tracking inbox and its facets listed hidden books and series; - recommendations marked a recommended series as already in Codex, with its id, when it exists but is hidden. A restricted reader now only sees genres and tags carried by a series they can see, counted over those series; search, errors and the release inbox apply their visibility in SQL, with totals to match; a duplicate group keeps only its visible members and disappears when fewer than two remain; and a hidden series is reported as not in Codex. Inventory metrics are refused to accounts with content restrictions rather than filtered: they report library-wide totals, per-library counts and database size, which describe the whole server. Unrestricted users see the same genre and tag lists as before, now from one grouped count query instead of one count per name.
The entity and task event streams sent every event to every reader. A restricted reader's client was told when a hidden series or book was created, updated, recovered or deleted, and release announcements carried the hidden series' title. The task stream reported work on hidden books, and the scan progress stream reported scans of libraries the reader cannot see. Each subscriber now only receives events about content it can see, checked per event against the cached filter, so it adds no queries for an unrestricted user. When visibility cannot be determined the event is withheld rather than sent. KOReader sync found books by their KOReader hash without any visibility check, so a reader could read or write progress on a hidden book. Hidden books are now left out before the lookup decides, so a hidden book answers like a missing one and a hidden copy of a visible book no longer makes the hash ambiguous.
…y check Visibility used to be a per-handler convention, and most handlers forgot it. A unit test now scans the route handlers and fails, naming the handler, when one takes a series, book or library id from its path and never checks that the user can see it. Admin-only handlers are exempt, since admins are never restricted.
With rules on libraries, genres, tags and sharing tags spread over several groups, "can my kid see this series?" had no direct answer; the effective grants view only listed sharing tags. The user editor now has a "What this user sees" panel, backed by a new visibility preview endpoint: how many series the user can and cannot see, which libraries are visible to them, and for any series an admin picks, 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. The explanation runs the same matching code as enforcement, fed with only that series, and a test checks the two agree, so the preview cannot tell an admin something different from what the user gets. Creating a library now says that readers whose groups use allow rules will not see it until a group allows it. The access group docs explain the preview, and that restrictions hold on every surface (OPDS, Komga, KOReader, live updates), including the Komga compatibility notes that still said content restrictions were not implemented.
The inventory, plugin and task metrics endpoints only required libraries:read, which every reader has, while the only page that shows them (Settings > Metrics) is linked for admins alone. They report the user count, database size, every library's totals and plugin and task activity, none of which a reader needs, and the inventory totals would also tell a restricted reader how much content is hidden from them. All four read endpoints now require admin, like the metrics cleanup endpoints already did, which also replaces the special case that refused inventory metrics to accounts with content restrictions. The metrics page now sends non-admins home instead of rendering a page whose requests can only fail. BREAKING CHANGE: GET /api/v1/metrics/inventory, /metrics/plugins, /metrics/tasks and /metrics/tasks/history return 403 for non-admin users. No Codex client calls them as a non-admin.
The previous commit made the metrics endpoints admin-only on the claim that no Codex client calls them as a non-admin. That was wrong: the iOS app shows inventory and task metrics from its Tasks screen, which it offers to maintainers, so they would have hit a 403 there. The four metrics reads now require tasks:read, which admins and maintainers hold and readers do not. Readers are still kept out, which is what the change was for; maintainers are never restricted by access groups, so the totals reveal nothing to them. The web metrics page uses the same permission instead of an admin check. BREAKING CHANGE (narrowed): the metrics endpoints now return 403 for readers only, where they used to accept any user with libraries:read.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Access groups could only allow or deny sharing tags. Restricting a kids account meant sharing-tagging every series by hand, new series were uncovered until someone tagged them, and a new account with no group saw the whole library.
Separately, content visibility was enforced per handler, and most handlers never checked. A restricted reader could open hidden pages and covers by id, download a hidden series as a zip, and see hidden content through Komga, OPDS, KOReader sync and the live event streams.
What changes
Access group rules
GET/PUT /api/v1/access-groups/{id}/rulesreplaces a group's rule set atomically. The admin UI edits all four rule types.Default group
default) and can be removed. Add all existing users brings in accounts that already exist.Memberships and OIDC
Visibility on every surface
inCodexflag.tasks:read, held by admins and maintainers. The web metrics page uses the same permission and sends anyone else home.Performance
Preview
GET /api/v1/users/{id}/visibility-preview) shows:Cleanup
SharingTagRepository. They had different semantics (untagged series always visible, groups ignored).Breaking change
GET /api/v1/metrics/inventory,/metrics/plugins,/metrics/tasksand/metrics/tasks/historynow requiretasks:read(admins and maintainers) instead oflibraries:read, so readers get 403. Codex Reader for iOS shows these metrics from its Tasks screen, which it only offers to maintainers and admins, so it keeps working.Testing
tests/api/content_visibility.rs). Each checks that the visible item still works and that hidden items answer as missing. Each was confirmed to fail on the code before its fix.