- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style. - Adjusted punctuation and phrasing for better flow and understanding.
182 lines
9.8 KiB
Markdown
182 lines
9.8 KiB
Markdown
# 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](../architecture/overview.md#the-three-clients) 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](../architecture/authentication.md#claims) 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:
|
|
|
|
```python
|
|
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](../architecture/overview.md#request-lifecycle)), 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:
|
|
|
|
```python
|
|
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](../architecture/multi-tenancy-and-rls.md#the-two-layer-contract) for why
|
|
both layers are required, and [Adding an org-scoped model](adding-an-org-scoped-model.md) 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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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,
|
|
|
|
```python
|
|
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,
|
|
|
|
```diff
|
|
- 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](../architecture/permissions-and-roles.md#object-level-checks) 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`:
|
|
|
|
```python
|
|
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](testing.md) for the fast-iteration commands,
|
|
and [Adding an org-scoped model](adding-an-org-scoped-model.md#the-tests) for the same pairing
|
|
applied to tenant isolation specifically.
|