Skip to content

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.FindAsync bypasses query filters and must not be used for filtered entities; use AcrossAllGroups().FirstAsync(...) or filtered queries instead