Skip to content

fix(rules): lock Watchlists reads to playlist members - #138

Draft
larabail wants to merge 5 commits into
masterfrom
larabail-watchlist-member-reads-gated
Draft

fix(rules): lock Watchlists reads to playlist members#138
larabail wants to merge 5 commits into
masterfrom
larabail-watchlist-member-reads-gated

Conversation

@larabail

Copy link
Copy Markdown
Owner

Caution

Do not merge until production is on 3.18.x. This is #110, restored after
it was merged early and reverted in #137. Nothing about the change was wrong;
the timing was.

Merging is the deploy. The release runs
firebase deploy --only functions,firestore:rules,firestore:indexes on any
merge touching one of them, so squashing this restricts playlist reads on the
live project within one release run. There is no manual step left to hold.

The gate, concretely: Play Console must show the pre-3.18 clients are
gone. When #110 was merged, the most recent promotion to the Play production
track had shipped 3.17.4 -- a client that finds its playlists by reading
the whole Watchlists collection, which is exactly what this rule refuses.
Every production user would have lost access at once, since a rules deploy
has no staged rollout.

The condition to check before merging:

  1. A build of 3.18.x or later has been promoted to Play production, not
    just internal testing.
  2. Play Console shows active installs on that version, with the pre-3.18
    tail gone.

Whether adoption is high enough is a human call, not mine.

Restores #110. Original description follows.


Closes #41.

What this changes

match /Watchlists/{listId} carried allow read: if signedIn(), so any
authenticated account could read every playlist in the collection, including
ones it had never been invited to. Each playlist document carries its own
access code, so enumerating the collection also handed over the codes needed to
join the lists it turned up. This was KNOWN GAP #1 in firestore.rules.

Reads now require membership. The non-member allow update branch goes with
them: it existed so an older client could join a list by writing itself into
Users, which joinPlaylist replaced by verifying the code on the server, and
once reads are closed it is only an open door. Gap #1 is removed from the
KNOWN GAPS block and the remaining two are renumbered.

Why this way

Membership is accepted from either memberUids or Users, and that is not
redundancy.
This is the part worth reviewing.

Firestore allows a query only when the query's own filters prove every document
it could return is readable — rules are not filters. Nothing can filter on
Users, because arrayContains matches whole elements and the role is part of
the element ({uid: "Owner"}). So the obvious rule the issue describes,
allow read: if playlistMember(resource.data) reading Users, refuses the
where("memberUids", arrayContains: uid) query the app makes to find its own
lists.
I confirmed this against the emulator rather than assuming it:

rule reads member get member's memberUids query non-member get collection scan
Users only allowed denied denied denied
memberUids only allowed allowed denied denied
either allowed allowed denied denied

Reading only memberUids fixes the query but introduces a different problem:
syncPlaylistMembers is an onDocumentWritten trigger, so a playlist not
written since it was deployed has no memberUids at all, and its own members
lose access. Accepting either field allows the query and still covers those
documents, while refusing a non-member on both. There is a regression test for
each half.

Backfilling memberUids across the collection and dropping the Users arm is
the tidier end state. It needs a migration over live data, so it is
deliberately left undone here rather than bundled into a change that is
already gated on adoption.

The 'memberUids' in data guard is load-bearing: testing a missing field in
rules is an error, not a false.

One client path had to change. lib/popups/list_edit_popup.dart found the
document to save by downloading the entire Watchlists collection and matching
on name and access code, so the new rule would have broken editing a playlist.
It writes by document id now — which it already had. The old search was also
wrong on its own terms: it edited every list sharing a name and code, and
silently saved nothing when the module-level globals it matched against had
gone stale. Both now have tests.

Every other client path was checked and already addresses playlists by id or
through the memberUids query: playlist_service.dart, grant_access_dialogue.dart,
list_add_popup.dart, list_result.dart, movie_add_popup.dart,
tv_add_popup.dart, and joining via playlist_join.dart.

How it was tested

firestore-tests/ — the suite is the failing-before test for this fix. Against
the unchanged rules, four of the new cases fail:

1) does not let a signed-in non-member read a playlist
2) does not leak the access code to a non-member who guesses the id
3) does not let anyone download the whole collection
4) does not let a non-member add themselves to Users

With the change, 89 pass and none fail. New cases cover: a member reading their
own list; a non-member refused; the access code not leaking to someone who
guesses the id; a playlist with no memberUids yet still readable by its
members and still refused to others; the memberUids query returning only the
caller's lists; an unfiltered collection read refused; joining by direct write
refused; and an Owner still able to grant access.

test/popups/list_edit_popup_test.dart — both new cases fail against the old
dialogue and pass with it: editing the list it was opened with when another
shares its name and access code, and saving when the globals have gone stale.

  • flutter analyze — clean, no issues.
  • flutter test — 908 passing.
  • firestore-tests — 89 passing (was 83).

Note that CI does not run firestore-tests/ — no workflow references it, so
this suite only runs when someone runs it locally. That is not this PR's to fix,
but it is worth knowing when reviewing a rules change.

Not tested on a device: the rules were exercised against the Firestore emulator,
which is what that suite is for.

Checklist

  • Branched off master; no commits made directly on master
  • version: in pubspec.yaml bumped to match what this changes — 3.18.13.18.2 (PATCH, fix); verified with tool/check_version_bump.py
  • flutter analyze is clean
  • flutter test passes, and new behaviour has tests covering it
  • No new user-visible strings, so neither .arb file changed and gen-l10n was not needed
  • Security rule changes come with matching tests in firestore-tests/
  • README.md was updated — the syncPlaylistMembers entry now says memberUids is what makes a member-only read queryable
  • No commit carries a Co-authored-by trailer
  • Commit messages follow the convention in AGENTS.md

larabail and others added 5 commits August 22, 2026 12:20
Any authenticated account could read every document in Watchlists,
including playlists it had never been invited to. Each one carries its
access code, so enumerating the collection also handed over the codes
needed to join the lists it turned up.

Reads now require membership, and the non-member update branch goes with
them. That branch existed so an older client could join a list by writing
itself into `Users`; joinPlaylist replaced it by checking the code on the
server, and once reads are closed it is only an open door.

Membership is accepted from either `memberUids` or `Users`, which is not
redundancy. Firestore allows a query only when the query's own filters
prove every document it can return is readable, and nothing can filter on
`Users`: arrayContains matches whole elements and the role is part of the
element. A rule reading only `Users` therefore refuses the
`where("memberUids", arrayContains: uid)` call the app makes to find its
own lists. A rule reading only `memberUids` instead locks members out of
any playlist that syncPlaylistMembers, being a write trigger, has never
touched. Accepting both allows the query and still covers those
documents. Backfilling `memberUids` and dropping the `Users` arm is the
tidier end state, but it needs a migration over live data and is left
undone here.

The edit-a-playlist dialogue found its document by downloading the whole
collection and matching on name and access code, so the new rule would
have broken it. It writes by id now, which it already held: the old
search also edited the wrong list when two shared a name and code, and
silently saved nothing when the globals it matched against went stale.

Restricting the read breaks any build that still finds its lists by
reading the collection, everywhere at once, because a rules deploy has no
staged rollout. Merging does not do that: nothing in CI deploys this
file, whose only firebase deploy is `--only functions`. It takes effect
when someone runs `firebase deploy --only firestore:rules` by hand, and
that is the step to hold until Play Console shows the pre-3.18 clients
are gone.
Take master's wording for the friend-writes gap and this branch's numbering:
master reworded it while this branch renumbered it after removing the playlist
read gap that this change closes. Keep both new imports in the rules suite, and
keep this branch's version name over master's build number, at 3.18.7 because
the calendar and people-score fixes already claim 3.18.5 and 3.18.6.
Correct what this rule says about its own deployment. The comment was written
when nothing in CI deployed firestore.rules, so it told the reader to hold a
manual `firebase deploy` until the old clients were gone. The release now
deploys the rules and the indexes alongside the functions, so the merge itself
is what reaches every phone and the merge is what has to be held.

Take 3.18.7 over master's build number, since 3.18.5 and 3.18.6 are taken by
the calendar and people-score fixes.
Keep 3.18.7 over master's build number; the people-score fix took 3.18.6.
Master reverted this change in #137 because it was merged before the client
adoption it is gated on. The revert nets out against the original merge, so
this branch keeps the restriction and takes 3.18.10 over master's build
number.
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.

fix(rules): lock Watchlists reads to playlist members

1 participant