256 lines
12 KiB
Markdown
256 lines
12 KiB
Markdown
# Adding an org-scoped model
|
|
|
|
This page walks through adding a new tenant-owned model end to end, using a worked example. A
|
|
`CallLog` model recording calls made against a `Lead`, so every code block is something you could
|
|
actually drop into the tree, not pseudocode. It assumes you've read
|
|
[Multi-tenancy and RLS](../architecture/multi-tenancy-and-rls.md), which explains *why* each of
|
|
these steps exists; this page is the checklist for *doing* it.
|
|
|
|
## The checklist
|
|
|
|
1. **Inherit `BaseOrgModel`** (`common.base`) so the model gets an `org` foreign key, the UUID
|
|
primary key and audit fields.
|
|
2. **Add the table's `db_table` name to `ORG_SCOPED_TABLES`** in `backend/common/rls/__init__.py`.
|
|
3. **Write a migration** that calls `get_enable_policy_sql('your_table')` inside a `RunPython`
|
|
operation, with a `dependencies` entry that guarantees the table already exists when it runs.
|
|
4. **Filter every queryset** that touches the model by `org=request.profile.org`.
|
|
5. **Pass `org=...` explicitly** on every `create()` / `serializer.save()`, never trust a
|
|
client-supplied org.
|
|
6. **Write a test for both the allowed and the denied path**: an in-org request that succeeds, and
|
|
a cross-org request that doesn't see or can't write the row.
|
|
|
|
Steps 2 and 3 are both required and neither substitutes for the other: registering the table
|
|
without a migration that actually calls `get_enable_policy_sql` leaves the table with no policies at
|
|
all, and a migration that calls `get_enable_policy_sql` without registering the table in
|
|
`ORG_SCOPED_TABLES` means `manage_rls --status` can never report on it, so a gap there is invisible
|
|
to the one tool meant to catch it. See
|
|
[Common mistakes](../self-hosting/postgresql-and-rls.md#common-mistakes) for both failure modes
|
|
described in more detail.
|
|
|
|
## The model
|
|
|
|
```python
|
|
# backend/leads/models.py
|
|
from django.db import models
|
|
|
|
from common.base import BaseOrgModel
|
|
|
|
class CallLog(BaseOrgModel):
|
|
"""A record of an outbound or inbound call made against a lead."""
|
|
|
|
DIRECTION_CHOICES = (
|
|
("OUTBOUND", "Outbound"),
|
|
("INBOUND", "Inbound"),
|
|
)
|
|
|
|
lead = models.ForeignKey(Lead, on_delete=models.CASCADE, related_name="call_logs")
|
|
direction = models.CharField(max_length=10, choices=DIRECTION_CHOICES)
|
|
notes = models.TextField(blank=True, default="")
|
|
duration_seconds = models.PositiveIntegerField(default=0)
|
|
|
|
class Meta:
|
|
verbose_name = "Call Log"
|
|
verbose_name_plural = "Call Logs"
|
|
db_table = "call_log"
|
|
ordering = ("-created_at",)
|
|
indexes = [models.Index(fields=["org", "-created_at"])]
|
|
```
|
|
|
|
`BaseOrgModel` is abstract, and. This is easy to get wrong, and this codebase has a live example of
|
|
getting it wrong. A subclass's own `Meta` class does **not** automatically inherit the abstract
|
|
parent's `indexes` just because the model inherits `BaseOrgModel`. `Order` (`backend/orders/models.py`,
|
|
one of the few models here that actually inherits `BaseOrgModel` rather than hand-declaring its own
|
|
`org` foreign key) declares its own `Meta` with `db_table`, `verbose_name`, and `ordering`, but no
|
|
`indexes`, so `Order._meta.indexes` is `[]` at runtime, not the `["org", "-created_at"]` index
|
|
`BaseOrgModel.Meta` defines. `backend/orders/migrations/0003_remove_order_orders_org_id_9026a7_idx_and_more.py`
|
|
is the receipt: it drops that exact index from both `order` and `order_line_item`, tables that
|
|
otherwise get every other query filtered by `org`. `PersonalAccessToken` (`backend/common/models.py`)
|
|
is the codebase's real example of doing this correctly: its own `Meta` explicitly redeclares
|
|
`indexes = [models.Index(fields=["org", "-created_at"])]` rather than assuming it's inherited, which
|
|
is exactly what the `CallLog` example above copies. `BaseOrgModel` reliably gives you `id` (UUID
|
|
primary key), `org`, `created_at`, `updated_at`, `created_by`, and `updated_by` regardless of whether
|
|
your model declares its own `Meta`. Those are fields and a `save()` override on the class itself, not
|
|
something `Meta` mediates. The `["org", "-created_at"]` index is the one thing on `BaseOrgModel` that
|
|
your own `Meta` can silently drop, so redeclare it (as above) any time your model needs its own
|
|
`Meta` for a `db_table`, `ordering`, or anything else.
|
|
|
|
## Registering the table
|
|
|
|
Add the table's `db_table` name (`call_log`, matching `Meta.db_table` above) to
|
|
`ORG_SCOPED_TABLES` in `backend/common/rls/__init__.py`. This list currently has 61 entries (see the
|
|
file itself for the current count. It changes as the project grows, so don't rely on a number
|
|
copied from documentation); a new entry looks like any other:
|
|
|
|
```python
|
|
# backend/common/rls/__init__.py
|
|
ORG_SCOPED_TABLES = [
|
|
...
|
|
"task",
|
|
"invoice",
|
|
"call_log", # CallLog. Call records against a lead
|
|
# Supporting entities
|
|
"comment",
|
|
...
|
|
]
|
|
```
|
|
|
|
## The RLS migration
|
|
|
|
Write a dedicated migration for the new table's policies, modeled on a real one already in the tree,
|
|
`backend/opportunity/migrations/0012_enable_rls_sales_goal.py`:
|
|
|
|
```python
|
|
# backend/leads/migrations/0017_enable_rls_call_log.py
|
|
from django.db import connection, migrations
|
|
|
|
from common.rls import get_disable_policy_sql, get_enable_policy_sql
|
|
|
|
TABLE = "call_log"
|
|
|
|
|
|
def enable_rls(apps, schema_editor):
|
|
if connection.vendor != "postgresql":
|
|
return
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(get_enable_policy_sql(TABLE))
|
|
|
|
|
|
def disable_rls(apps, schema_editor):
|
|
if connection.vendor != "postgresql":
|
|
return
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(get_disable_policy_sql(TABLE))
|
|
|
|
|
|
class Migration(migrations.Migration):
|
|
atomic = False
|
|
|
|
dependencies = [
|
|
("leads", "0016_call_log"), # the migration that actually creates call_log
|
|
]
|
|
|
|
operations = [
|
|
migrations.RunPython(enable_rls, disable_rls),
|
|
]
|
|
```
|
|
|
|
`atomic = False` matches every RLS migration in this codebase; `DROP`/`CREATE POLICY` each take an
|
|
`ACCESS EXCLUSIVE` lock, so committing per-statement instead of holding one long transaction keeps
|
|
each lock brief. `get_enable_policy_sql` itself opens with `DROP POLICY IF EXISTS` before creating
|
|
either policy, so the migration is idempotent if it's ever re-run.
|
|
|
|
!!! warning "The dependency is not decorative. Get it wrong and the migration silently does nothing"
|
|
`dependencies = [("leads", "0016_call_log")]` above is what guarantees Django applies this
|
|
migration *after* the one that runs `CreateModel` for `CallLog`. If that dependency were missing
|
|
or pointed at an earlier migration, Django would be free to apply this one first, and
|
|
`ALTER TABLE "call_log" ENABLE ROW LEVEL SECURITY` against a table that doesn't exist yet would
|
|
raise a hard error, not fail silently, *unless* the migration is written defensively with an
|
|
existence check first. `backend/common/migrations/0034_rls_pipeline_tables.py` is exactly that
|
|
defensive style. It checks `get_check_table_exists_sql()` before stamping each table, because
|
|
it registers six tables across three different apps in one migration and couldn't express a
|
|
single clean dependency chain for all of them. Its own comment explains the cost: get the
|
|
dependencies wrong there and the check silently prints `Skipping <table> (table does not exist)`
|
|
and moves on. The migration "succeeds," CI is green, and the table simply never gets a policy.
|
|
For a single new model in one app, prefer the plain `opportunity/0012` shape above (a hard
|
|
dependency, no existence check, a real error if the ordering is ever wrong) over copying
|
|
`0034`'s defensive pattern: a loud failure during development is much cheaper than a policy
|
|
that quietly never gets created.
|
|
|
|
## The view
|
|
|
|
Every view touching the new model needs both the standard permission classes and an explicit org
|
|
filter. RLS is the safety net, not the contract (see
|
|
[The two-layer contract](../architecture/multi-tenancy-and-rls.md#the-two-layer-contract)):
|
|
|
|
```python
|
|
# backend/leads/views/call_log_views.py
|
|
from django.shortcuts import get_object_or_404
|
|
from rest_framework import status
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
from common.permissions import HasOrgContext
|
|
from leads.models import Lead, CallLog
|
|
from leads.serializer import CallLogSerializer
|
|
|
|
|
|
class CallLogListView(APIView):
|
|
permission_classes = (IsAuthenticated, HasOrgContext)
|
|
|
|
def get(self, request, lead_id, format=None):
|
|
lead = get_object_or_404(Lead, id=lead_id, org=request.profile.org)
|
|
logs = CallLog.objects.filter(org=request.profile.org, lead=lead)
|
|
return Response(CallLogSerializer(logs, many=True).data)
|
|
|
|
def post(self, request, lead_id, format=None):
|
|
lead = get_object_or_404(Lead, id=lead_id, org=request.profile.org)
|
|
serializer = CallLogSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
serializer.save(org=request.profile.org, lead=lead, created_by=request.user)
|
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
```
|
|
|
|
Two things to notice: the `Lead` lookup itself is org-filtered (`get_object_or_404(Lead, id=lead_id,
|
|
org=request.profile.org)`), so a cross-org caller gets a 404 on the parent object before ever
|
|
reaching the `CallLog` query, and `org` is passed to `serializer.save()` explicitly rather than
|
|
accepted from `request.data`, the same [server-owned field](security-rules.md#server-owned-fields)
|
|
rule that applies everywhere else.
|
|
|
|
## The tests
|
|
|
|
Write both directions, using the shared fixtures in `backend/conftest.py` (`org_a`, `org_b`,
|
|
`admin_client`, `org_b_client`, and the rest), modeled on the real pair in
|
|
`opportunity/tests/test_sales_goals.py`. `org_a`, `org_b`, `admin_client`, and `org_b_client` are
|
|
real, shared fixtures already defined in `backend/conftest.py`; `lead_in_org_a` below is not. It's a
|
|
local, file-scoped fixture this example defines itself, the same way `leads/tests/
|
|
test_lead_access_and_totals.py` has its own local `_lead()` helper rather than a shared one (there is
|
|
no org-scoped `Lead` fixture shared across the suite, because what fields a test needs on its `Lead`
|
|
varies too much to make one worth sharing):
|
|
|
|
```python
|
|
import pytest
|
|
|
|
from leads.models import CallLog, Lead
|
|
|
|
|
|
@pytest.fixture
|
|
def lead_in_org_a(org_a):
|
|
return Lead.objects.create(
|
|
first_name="Ada",
|
|
last_name="Lovelace",
|
|
email="ada@example.com",
|
|
status="in process",
|
|
company_name="Analytical Engines",
|
|
org=org_a,
|
|
)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_admin_can_create_call_log(admin_client, org_a, lead_in_org_a):
|
|
response = admin_client.post(
|
|
f"/api/leads/{lead_in_org_a.id}/call-logs/",
|
|
{"direction": "OUTBOUND", "notes": "Left voicemail"},
|
|
format="json",
|
|
)
|
|
assert response.status_code == 201
|
|
assert CallLog.objects.filter(lead=lead_in_org_a, org=org_a).exists()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_org_b_cannot_read_org_a_call_logs(org_b_client, org_a, lead_in_org_a):
|
|
"""The denied path: mirrors test_org_isolation in test_sales_goals.py."""
|
|
CallLog.objects.create(lead=lead_in_org_a, org=org_a, direction="OUTBOUND")
|
|
|
|
response = org_b_client.get(f"/api/leads/{lead_in_org_a.id}/call-logs/")
|
|
|
|
# The parent Lead lookup is org-scoped, so a foreign org gets a 404 on the
|
|
# lead itself, before the CallLog query ever runs.
|
|
assert response.status_code == 404
|
|
```
|
|
|
|
The second test is the one that actually earns its keep: a test suite that only ever checks the
|
|
allowed path can pass at 100% coverage while the org filter above is missing entirely. See
|
|
[Testing](testing.md#the-sqlite-caveat) for why this particular pair of tests is also the only thing
|
|
proving the ORM filter is correct, independent of whether RLS is even enforced in the environment the
|
|
test runs in.
|