ADR-001: Group Scoping via Named Query Filters¶
Status¶
Accepted
Context¶
Every data entity is owned by a group, and cross-group reads are a data leak. Scoping was previously enforced by explicit ForGroup(groupId) extension calls in every query handler — correct only as long as no handler ever forgot the call. A forgotten predicate failed silently by returning another group's data.
Decision¶
Group scoping is enforced centrally by a named EF Core global query filter (QueryFilters.GroupScope, GroupId == ICurrentUserAccessor.GroupId) applied in DataContext.OnModelCreating to Marker, Transaction, RecurringTransaction, BudgetAccount, Category, Note, and ActionHistory. Handlers never write GroupId == predicates. Background jobs and admin code opt out explicitly with AcrossAllGroups(), which removes only the group filter (the SoftDelete filter stays active). GroupModule is excluded because admin endpoints read module configuration across groups. Write-side stamping remains explicit via IRequestWithCurrentGroupId.
Consequences¶
- A forgotten predicate can no longer leak cross-group data; a forgotten opt-out fails loudly with an empty result
AcrossAllGroups()call sites form a complete, auditable list of cross-group reads- Blanket
IgnoreQueryFilters()is forbidden — it would drop group scoping along with soft delete DbSet.FindAsyncbypasses query filters and must not be used for filtered entities; useAcrossAllGroups().FirstAsync(...)or filtered queries instead