Skip to content

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,

request.profile == self.object.created_by  # always False: Profile compared to a User FK

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.