Skip to Content
ContributeBackendSecurity Improvements

Security Improvements: Organization Filtering

Tenant isolation is enforced primarily by the ORM auto-filter and PostgreSQL row-level security described in Multi-tenancy. This page covers the tooling that guards those guarantees against regressions in code paths that add explicit organization_id filters (or bypass the ORM).

CI scan for missing filters

scripts/check_organization_filtering.py scans the codebase for database queries on organization-scoped models that may be missing an organization_id filter. It classifies findings as HIGH (queries on organization-aware models) or MEDIUM (potentially unsafe) severity and exits non-zero when HIGH-severity issues are found, so it can gate a build.

check-org-filtering.sh
python scripts/check_organization_filtering.py --verbose

# Generate GitHub Actions workflow files for CI integration:
python scripts/check_organization_filtering.py --setup-ci

Exit codes: 0 no issues, 1 issues to address, 2 script error.

Regression tests

Cross-tenant isolation is covered by the suite under tests/backend/security/, marked with the security pytest marker:

run-security-tests.sh
# Cross-tenant isolation, org filtering, RLS coverage, service-level checks
pytest ../../tests/backend/security/ -v

# Or run every security-marked test:
pytest -m security

Key files: test_organization_filtering.py, test_cross_tenant_isolation.py, test_rls_coverage.py, and test_service_security.py.

Writing org-safe queries

When a query bypasses the ORM auto-filter (raw SQL, db.execute(select(...)), bulk operations), add the tenant predicate explicitly. ID lookups on globally unique UUIDs are safe without it; list and search queries on non-unique fields are not:

org-safe-queries.py
# Safe: UUID primary-key lookup is globally unique.
entity = db.query(Entity).filter(Entity.id == entity_id).first()

# Unsafe without an explicit org filter: list/search on non-unique fields.
rows = db.query(Entity).filter(
    Entity.name == name,
    Entity.organization_id == organization_id,  # required
).all()