Project-wide conventions for Sigra contributors. These are not rules the compiler enforces — they are the discipline that keeps the library's security guarantees real.
Sigra's multi-tenancy model rests on one invariant: every query that
touches an organization_id-bearing schema must be scoped to the caller's
active organization. Violations cause cross-tenant data leaks (Pitfall
O-1 in the v1.1 research).
Sigra enforces this with two layers.
Every context function that reads an org-scoped resource must pass the
query through Sigra.Organizations.Query.for_org/2 before handing it to
the Repo:
# Good
Post
|> Sigra.Organizations.Query.for_org(scope)
|> Repo.all()
# Bad — reads every org's posts
Repo.all(Post)for_org/2 accepts either a %Scope{} (reads scope.active_organization.id)
or a raw binary organization ID. It raises ArgumentError if the schema
does not have an :organization_id field, so bugs surface at the call
site instead of leaking data.
This is the layer contributors see. Discipline here is what keeps the library correct.
If a contributor forgets Layer 1, Sigra.Organizations.Query.maybe_enforce_org_scope/4
catches the omission at Repo time via the host app's generated
prepare_query/3 callback. It inspects the query's WHERE expressions for
an organization_id filter and raises ArgumentError if the schema is in
the enforced list and no filter is present.
This layer is load-bearing: it makes cross-tenant leaks loud in
development and test even when Layer 1 is forgotten. Do not rely on it as
the primary guard — the error message at a missing for_org/2 call is
much clearer than an error at the Repo.
Sigra shipped the DX-09 spike as a narrow custom Credo check:
Sigra.Credo.NoUnscopedOrgQueryInLib.
It flags obvious unscoped Repo.all/one/get/get_by calls on known
org-scoped schemas inside lib/sigra/** when the call site skips
Sigra.Organizations.Query.for_org/2. This is defense-in-depth, not a
replacement for Layer 1. Contributors should still treat for_org/2 as
the primary scoping discipline and rely on the lint rule only to catch the
most obvious drift.
Some queries are legitimately unscoped: admin dashboards, cross-tenant
analytics, the installer's schema inspection. Pass skip_org_check: true
in Repo opts to bypass Layer 2:
# Legitimate: admin dashboard listing all orgs
Repo.all(Organization, skip_org_check: true)Every skip_org_check: true call site is a documented exception.
Reviewers should push back on new usages that are not admin-scoped,
installer-scoped, or audit-scoped.
Layer 2's filter detection walks the query's WHERE expression AST looking
for references to an :organization_id field. This is a heuristic, not a
prover:
- It recognizes direct comparisons (
r.organization_id == ^org_id) and references nested inside compound boolean expressions. - It can false-positive on cross-tenant joins
(
r.organization_id == r2.organization_id) and onis_nil(r.organization_id). Neither of these constrains the query to a specific tenant, but the heuristic will pass them through. - On inspection failure (malformed AST, future Ecto version changes), it logs a warning and passes the query through to avoid false positives.
The heuristic's limitations are accepted per the Phase 13 threat model (T-13-08, disposition: accept) because Layer 1 is the primary enforcement and Layer 2 is defense-in-depth. If a tighter check becomes feasible (e.g., pinned-literal matching), revisit the threat model before tightening — false positives on valid queries would be worse than the current accepted false-positive-on-join gap.
References:
lib/sigra/organizations/query.ex—for_org/2+maybe_enforce_org_scope/4.planning/phases/13-organizations-schemas-context/13-03-PLAN.md— D-24 decision- Phase 13 threat model (T-13-08)