Skip to content

Access groups: rules on libraries, genres and tags, default group, full filter coverage - #52

Merged
AshDevFr merged 16 commits into
mainfrom
feat/access-group-rules
Oct 26, 2026
Merged

AshDevFr merged 16 commits into
mainfrom
feat/access-group-rules

Conversation

@AshDevFr

@AshDevFr AshDevFr commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

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

  • A group can allow or deny libraries, genres, regular tags and sharing tags.
  • How rules combine:
    • Inside a group, values of one type match "any of" by default. Genres, tags and sharing tags can be switched to "all of". Different types must all match, so "library Manga + genre Kids" means Kids-genre manga.
    • Across groups, allows add up. Personal sharing-tag grants count as one more group.
    • Denies from every group and grant are global and always win.
    • Existing sharing-tag setups resolve exactly as before.
  • Genre and tag rules match by normalized name, not id. Cleanup deletes unused genres and tags and they come back under new ids, so an id-keyed rule would silently drop a deny.
  • GET/PUT /api/v1/access-groups/{id}/rules replaces a group's rule set atomically. The admin UI edits all four rule types.
  • Admins and maintainers are never restricted. Only readers are. This is stated in the UI and the docs.

Default group

  • One group can be the default. A partial unique index enforces this on SQLite and PostgreSQL.
  • Every new account joins it in the same transaction as the account: admin-created, self-registered, and first OIDC login.
  • It's an ordinary membership (source default) and can be removed. Add all existing users brings in accounts that already exist.

Memberships and OIDC

  • An OIDC login fails if group reconciliation fails. Before, the user got in with no groups, which meant full visibility. Reconciliation now runs in a transaction.
  • Adding a user to a group again from another source no longer rewrites the membership's source. The IdP can still remove what it added, and can't remove what an admin assigned.
  • The users list shows each user's access groups.

Visibility on every surface

  • Every handler that takes a series, book or library id checks visibility first: about 170 handlers across the native API, OPDS 1.2, OPDS 2.0 and Komga. A hidden item answers 404, exactly like a missing one.
  • Endpoints that take ids in the body (bulk read/unread, bulk want-to-read, reading sessions) skip hidden ids the way they skip unknown ones.
  • Libraries:
    • Hidden when a deny rule names them.
    • Under allow rules, shown only if an allow rule names them or they hold a visible series.
    • Lists, counts and library-scoped endpoints follow this on every surface.
  • Lists: Komga series and book queries, genre and tag lists (names and counts), OPDS search, books in error, duplicates, the release inbox and its facets, and recommendations' inCodex flag.
  • Server metrics (inventory, plugin, task and task-history endpoints) are no longer open to readers: they require tasks:read, held by admins and maintainers. The web metrics page uses the same permission and sends anyone else home.
  • Live updates: the entity, task and scan SSE streams only send each subscriber events about content they can see.
  • KOReader sync: a hidden book's hash answers like a missing one.
  • Guard test: a unit test fails, naming the handler, when a new handler takes a content id without a visibility check.

Performance

  • Each process caches a user's built filter for 30 seconds, size-capped and evictable at any time. Page images under webtoon prefetch no longer rebuild it per request.
  • Every repository write that changes visibility bumps an access-control version. A rule, membership, grant or role change applies on the next request on that process, and other processes catch up within the TTL.
  • Unrestricted users' checks run no query at all.

Preview

  • Settings → Users → What this user sees (GET /api/v1/users/{id}/visibility-preview) shows:
    • visible and hidden series counts, and which libraries the user can see;
    • for any series, why it is shown or hidden: the matching deny and the group it comes from, the group that allows it, or that no allow rule matches.
  • It uses the same matching as enforcement, and a test checks that they agree.

Cleanup

  • Removed dead visibility helpers on SharingTagRepository. They had different semantics (untagged series always visible, groups ignored).

Breaking change

GET /api/v1/metrics/inventory, /metrics/plugins, /metrics/tasks and /metrics/tasks/history now require tasks:read (admins and maintainers) instead of libraries: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

  • Content filter: DB-backed tests for every evaluation rule, library visibility, the admin and maintainer bypass, and explanations agreeing with enforcement. The original sharing-tag tests pass unchanged.
  • Cache: invalidation tests that fail when the version bump is disabled.
  • Restricted-reader integration tests per surface (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.
  • Repository and API tests for rules, the default group on each account creation path, membership sources, OIDC reconciliation, add-all-users, the users list and the preview.
  • Frontend tests for the rules editor, default-group card, user badges, the preview panel and the new-library notice.

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.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Deploying codex with  Cloudflare Pages  Cloudflare Pages

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

View logs

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

API contract changes

Compared against main. These are changes a client generated from the
previous document would notice. Not a failure: breaking changes are a
release-time decision, and make release-prepare checks the bump against
them when the version is chosen.

Report
No breaking changes

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.
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Coverage

Backend (Rust)

Crate Lines Functions Regions
codex 47.4% 55.8% 47.4%
codex-api 75.4% 56.2% 74.0%
codex-cli-common 74.6% 84.6% 74.2%
codex-config 97.6% 97.1% 96.4%
codex-db 91.4% 85.7% 89.0%
codex-events 92.0% 96.2% 93.8%
codex-migrate 77.8% 82.2% 74.8%
codex-models 91.8% 88.2% 92.5%
codex-parsers 82.5% 89.1% 82.1%
codex-scanner 91.7% 92.0% 91.2%
codex-scheduler 61.1% 61.6% 54.8%
codex-search 95.9% 96.9% 96.3%
codex-services 83.9% 86.1% 83.9%
codex-tasks 46.6% 52.1% 49.3%
codex-utils 98.0% 96.3% 97.8%
migration 77.1% 67.0% 76.4%
Total 81.8% 76.1% 81.2%

Frontend (web)

Lines Statements Functions Branches
64.2% 63.7% 52.4% 58.5%

Line-level reports (lcov) are attached to the workflow run as the coverage-rust and coverage-web artifacts.

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.
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.
@AshDevFr
AshDevFr marked this pull request as ready for review October 26, 2026 05:55
@AshDevFr
AshDevFr merged commit 3e9a267 into main Oct 26, 2026
25 checks passed
@AshDevFr
AshDevFr deleted the feat/access-group-rules branch October 26, 2026 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant