Security rules¶
BottleCRM is a multi-tenant application where every org's data sits in the same database, the same tables, next to every other org's. The rules on this page exist because the two ways that goes wrong, one tenant reading or writing another tenant's data, and a non-admin reaching admin- or platform-level actions, are the two outcomes every change touching auth, permissions, or serializers has to rule out before it merges.
The trust boundary¶
The backend (backend/) is the only trust boundary in this system.
Architecture overview describes why that has to be
true structurally, not just as policy: a SvelteKit web app, a Flutter mobile app, and any
third-party integration all talk to the same Django REST Framework API over the same JSON
endpoints. There is no separate, more-trusted internal API. A rule enforced only in the SvelteKit
UI (disabling a button, hiding a form field, client-side validation) protects none of the three
clients, because a browser DevTools console, a modified mobile build, or a plain curl request
reaches the same endpoint directly, skipping whatever the UI would have prevented. Every constraint
that matters has to be enforced in the DRF view or serializer, full stop.
Identity is server-derived¶
Who is making a request, which org they're acting in, and what role they hold are facts the server
establishes and re-validates on every request, never values read from the request body, a query
string, or a custom header. Authentication shows the
concrete mechanism: the org_id claim inside a caller's signed JWT is not simply trusted, the
middleware that resolves it re-checks it against an active Profile row on every single request:
profile = Profile.objects.select_related("org").get(
user_id=user_id, org_id=org_id, is_active=True
)
If that lookup fails (the membership was revoked, the profile was deactivated) the request gets a
403 asking the caller to sign in again, rather than proceeding on a claim that's gone stale. The
practical rule for any view or serializer you write: identity comes from request.user and
request.profile, both set by middleware before your code runs (see
Overview), never from a field the client sent.
role, in particular, is a display convenience when it appears in a JWT payload; every real
permission check reads request.profile.role fresh from the database, not from the token.
Tenant scoping¶
Pair every permission check with an explicit org filter. The two are independent controls, and
neither substitutes for the other. HasOrgContext (backend/common/permissions.py) is the standard
permission class on nearly every org-scoped view:
class HasOrgContext(permissions.BasePermission):
def has_permission(self, request, view):
if not hasattr(request, "profile") or request.profile is None:
return False
if not hasattr(request, "org") or request.org is None:
return False
if not request.profile.is_active:
return False
return True
That establishes "a real, active member of this org is making this request". It does not filter
which rows the view returns. Every queryset still has to add
Model.objects.filter(org=request.profile.org) itself, and every create() / serializer.save()
still has to pass org=request.profile.org explicitly, never accept it from the request body. RLS
(PostgreSQL Row-Level Security) backs this up at the database layer, but it's a safety net for when
the filter is missing, not a substitute for writing it. See
The two-layer contract for why
both layers are required, and Adding an org-scoped model for the
full checklist when the view is for a brand-new model.
Server-owned fields¶
Any field the server is supposed to derive: org, created_by, profile, a publish/visibility
flag, must never be settable from request.data. There are two real mechanisms in this codebase
for that, not one:
Declare the field read_only=True or list it in read_only_fields. TeamCreateSerializer in
backend/common/serializer.py declares read_only_fields = ("created_at", "created_by", "org");
leads/serializer.py sets read_only_fields = ("id", "created_at", "updated_at", "org") on
LeadStageSerializer and LeadPipelineSerializer; and TimeEntrySerializer
(backend/cases/serializer.py) declares the field itself read-only; profile =
ProfileSerializer(read_only=True): for exactly the reason this section opens with: profile is
who logged the time entry, and it has to be a fact the server sets, not one a client requests.
Or don't accept it through the serializer at all, and set it explicitly at the call site.
TimeEntryCreateSerializer's own docstring states this directly: "case, profile, and org are
injected by the view and not accepted from the client", and indeed none of the three appear in its
Meta.fields. The view (backend/cases/time_views.py) backs that up:
entry = TimeEntry.objects.create(
org=request.profile.org,
case=case,
profile=request.profile,
**serializer.validated_data,
)
serializer.validated_data can only ever contain the fields the serializer declared in the first
place, so there's nothing for a client to override here even by guessing field names, the
equivalent of popping a key out of request.data before save(), but enforced by what the
serializer's fields tuple omits rather than by a runtime pop.
The sharpest example of why this matters is is_sample, a flag shared, via one imported
SAMPLE_DATA_HELP_TEXT constant, by six models: Lead, Account, Contact, Case, Opportunity,
and Task (grep -rl SAMPLE_DATA_HELP_TEXT backend/ finds it in each app's models.py, plus
common/base.py where it's defined once). leads/serializer.py declares
read_only_fields = ("is_sample",). The help text itself, defined once in backend/common/base.py
and shared by every model that has the field, states the reasoning as a security invariant rather
than documentation:
SAMPLE_DATA_HELP_TEXT = (
"True only for demo rows created by common.packs.applier._apply_sample_data. "
"Server-set exclusively, never expose this as a writable field on any "
"serializer. It is the sole key common.packs.applier.clear_sample_data uses "
"to decide what to delete, so a client-writable path here would let a user "
"mark an arbitrary real record as sample and have it deleted."
)
If a field like this were writable, a hostile request wouldn't need a bug in the delete logic at all. It would just flip the flag on someone else's real record and let the legitimate "clear sample data" action do the deleting. When you add a field the server is meant to own, ask what a client could achieve by setting it directly, not just whether the happy path needs it to be read-only.
Prove permission checks can fail¶
A permission check that always evaluates to the same answer isn't a check. It's dead code wearing
a check's shape, and this codebase has a well-documented history of exactly that happening by
accident. Case.created_by (like nearly every model's created_by) is a foreign key to User, not
to Profile. Comparing a Profile instance directly against it,
Type-checks in Python and never raises, but is never True, which silently turned an intended
"creator or admin" check into "admin only" with no error anywhere. This isn't hypothetical in this
codebase: git log -L on CaseAttachmentView.delete in backend/cases/views.py shows a commit
(c9250dd) whose diff replaces exactly this,
- request.profile.role == "ADMIN"
- or request.profile.is_admin
- or request.profile == self.object.created_by
+ is_org_admin(request.profile)
+ or request.profile.user_id == self.object.created_by_id
Confirming from the actual commit history, not from a comment describing it, that the
Profile-vs-User comparison was live code before it was replaced with the type-correct
request.profile.user_id == self.object.created_by_id.
Permissions and roles documents this
pattern in depth, including which apps had it fixed and which, as of that page's own last check
against source, still don't; read it before writing a new created_by comparison, and compare
profile.user_id to obj.created_by_id, never profile to obj.created_by directly.
The only way to know a check can fail is to write a test that makes it fail. leads/tests/
test_lead_access_and_totals.py states the rule its own tests follow: "Every permission test asserts
both directions. A check that can only return False is indistinguishable from a check that is never
reached." In practice that means a pair of tests, not one, for example
opportunity/tests/test_sales_goals.py:
def test_create_goal_admin(self, admin_client, org_a):
...
response = admin_client.post(self.GOALS_URL, data, format="json")
assert response.status_code == 201
def test_create_goal_non_admin_forbidden(self, user_client):
...
response = user_client.post(self.GOALS_URL, data, format="json")
assert response.status_code == 403
A single test asserting only the 403 case would pass identically whether the admin check works
correctly or whether it denies everyone, admin included, because of a mistake like the one above.
Only the paired True test catches that. See Testing for the fast-iteration commands,
and Adding an org-scoped model for the same pairing
applied to tenant isolation specifically.