STORY-F-008: Tenant enforcement via Finnest.Repo.prepare_query + Tenant plug¶
Epic: Multi-Tenancy Priority: Must Have Story Points: 2 Status: Not Started Assigned To: Unassigned Created: 2026-04-17 Sprint: 2
User Story¶
As a developer or auditor,
I want every Ecto query automatically scoped to the current tenant via Finnest.Repo.prepare_query, with any query missing tenant context raising a hard error,
so that cross-tenant data leakage is structurally impossible (NFR-004) — no developer can forget an org_id filter because the Repo enforces it.
Description¶
Background¶
F-007 lands the Organisation + Office + User schemas. F-008 makes the Repo fail-closed — every query is tenant-scoped automatically via prepare_query/3, and queries without a set tenant context raise RuntimeError.
This is Commandment #31 in action: boundaries enforced by tooling, not convention. Any developer who forgets to scope will see a test fail the moment they run it.
Scope¶
In scope:
Finnest.Repo.prepare_query/3callback:- Reads
org_idfromFinnest.Core.Tenant.current_org_id/0(nil-safe — raise if not set ANDskip_org_scope: trueopt not passed) - Inspects the query; if the schema being queried has an
:org_idfield AND the query doesn't already filter byorg_id, injectswhere: r.org_id == ^org_id - Opt-out via
Repo.all(query, skip_org_scope: true)— used by: migrations, cross-tenant audit reports,id_mappingslookups during migration - Allowlist of schemas that don't have org_id (Organisation itself, IdMapping, future reference-data — match architecture tests in F-007)
FinnestWeb.Plugs.Tenant— plug that reads authenticated user'sorg_idfrom session/JWT and callsFinnest.Core.Tenant.put_org_id/1; rejects requests where authenticated user has no org (403)- Apply the Tenant plug to the
:authenticatedrouter pipeline (after auth, before controller) - Oban worker base:
Finnest.Core.OnbanWorkermacro — wrapsperform/1to set Tenant + CorrelationId from job args, clear on exit (ensures workers inherit the same enforcement) - Fully enforce the architecture test
tenant_query_raises_test.exs(stubbed in F-007): clear tenant context →Finnest.Repo.all(Finnest.Core.User)raises
Out of scope:
- Full list of Oban workers (none exist yet; macro is foundation for future ones in F-011+)
- LiveView session-level tenant propagation (handled in F-017 Agent Chat LV via
on_mounthook)
Technical Notes¶
prepare_query/3is called for every query — performance matters. Query inspection should be minimal (check if query'sfromclause's schema hasorg_idfield; check ifwheresalready contains anorg_idfilter; inject only when needed)- Inspection via
Ecto.Queryinternal API — brittle but standard Elixir practice. Write careful tests. - Edge case: joins with multiple schemas. Scope the primary table by
org_id; rely on FK integrity for joined tables to be org-consistent. Cross-schema JOINs are forbidden (AR-07) except for reference data, so this edge case is bounded. - Edge case:
Repo.aggregate/3,Repo.exists?/2,Repo.one/2— all go through prepare_query, should work uniformly - Oban job args include
"org_id"+"correlation_id"keys by convention; base worker macro reads both - Benchmark: prepare_query overhead should be <100µs per query in dev (real DB latency dominates)
Dependencies¶
- Blocked by: STORY-F-003 (Tenant module), STORY-F-007 (schemas to test against)
- Blocks: F-009 V2Repo (confirms the prepare_query hook doesn't accidentally apply to V2Repo), F-012 (agents rely on tenant injection), F-016 (event store writes need tenant context)
Acceptance Criteria¶
-
Finnest.Repo.prepare_query/3implemented; automatically scopes queries withorg_id -
skip_org_scope: trueopt bypasses scoping; used safely by migrations (verify:mix ecto.migratestill works) -
Finnest.Core.Tenant.clear(); Finnest.Repo.all(Finnest.Core.User)raisesRuntimeErrorwith message mentioning "tenant context" -
Finnest.Core.Tenant.put_org_id(org_a.id); Finnest.Repo.all(Finnest.Core.User)returns only org A's users (mixed-org data scenario test) -
Finnest.Core.Tenant.put_org_id(org_a.id); Finnest.Repo.get(Finnest.Core.User, user_b.id)returnsnil(not 403 — doesn't leak existence) - Explicit
where: u.org_id == ^org_b.idquery withorg_ain context raises (defensive — context mismatch detected) OR at minimum returns empty (acceptable fallback) -
FinnestWeb.Plugs.Tenantsets context; unauthenticated request has no context; authenticated sets correctly -
Finnest.Core.OnbanWorkermacro extracts org_id from job args; sets tenant; cleans up on exit - Architecture test
tenant_query_raises_test.exsnow fully passes (was stubbed in F-007) - Performance test: 10,000 queries through prepare_query in <200ms overhead (microbenchmark)
- Reference-data schemas (Organisation, IdMapping, and future CredentialType) on allowlist — their queries don't require tenant context
-
mix format,credo --strict,dialyzer,mix boundaryall green
Testing Requirements¶
- Unit: prepare_query logic — injects when schema has org_id, skips when not, respects opt-out
- Property-based test (StreamData): random query shapes always return tenant-scoped or raise
- Integration: multi-tenant scenario — Org A + Org B with data; tenant context swaps; results correctly filtered
- Chaos: deliberately broken test — try
Finnest.Repo.all(User)without context → clean error message, no hang, no leak - Oban worker test: job with
"org_id"arg runs with correct tenant context
References¶
../architecture/data.md§Tenant enforcement (full code sketch)../architecture/architecture.md§Architectural Invariants #4, NFR-004../10-GUARDRAILS.mdDA-11, AR-08- Commandment #31 (boundaries by tooling)