The patient API and sensitive-data access
A clinic wants to give patients a way to see their own appointments and reports — a mobile app, or a portal — which means an API. And the moment patients access their own medical records, access control stops being academic: one patient seeing another's reports is a real, serious data breach. This lesson builds Nidaan's patient-facing DRF API and, more importantly, enforces the layered access control that sensitive medical data demands. It is where the API and auth modules combine under real stakes.
The patient API: read-only, with DRF
A patient app needs to read the patient's own data, not manage the clinic — so a ReadOnlyModelViewSet
is the right granularity (list and retrieve, no create/update/delete for patients):
# api.py
from rest_framework import viewsets
from .serializers import AppointmentSerializer, TestReportSerializer
class MyAppointmentViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = AppointmentSerializer
permission_classes = [IsAuthenticated] # must be logged in
def get_queryset(self):
return (
Appointment.objects
.filter(patient__user=self.request.user) # ONLY this user's appointments
.select_related("doctor") # no N+1 in the API either
.order_by("-scheduled_for")
)
Two things carry the whole lesson. permission_classes = [IsAuthenticated] means an anonymous request is
rejected (verified pattern: 200 with a valid token, rejected without). And the get_queryset scopes the
data to the requesting user — filter(patient__user=self.request.user). This is the object-level access
technique from the auth module, and on an API it is the difference between a working patient portal and a
breach.
Why queryset scoping is the security, not filtering
It is tempting to think "the patient will only ask for their own records", but you must never trust the
client. If the API returned all appointments and relied on the app to filter, a patient could request
/api/appointments/42/ — someone else's — and get it. Scoping the queryset means the forbidden records
are not in the API's result set at all, so there is nothing to leak: retrieving another patient's
appointment id returns 404, because that id is not in this user's queryset. The security lives in the
get_queryset, enforced on the server, on every request — not in the client, and not in a filter the client
sends. This is the object-level-permissions lesson applied exactly where it matters most.
Sensitive uploads must not be at a public URL
Test reports carry uploaded scans, and a scan of a patient's medical result is exactly the kind of file that
must never sit at a public, guessable URL (the static-and-media lesson's warning, now with real stakes). If
MEDIA_URL served reports publicly, anyone could try /media/reports/2026/09/scan.pdf and, worse, iterate
filenames. The report file is served through a permission-checking view, not a public media URL:
def download_report(request, report_id):
report = get_object_or_404(
TestReport, id=report_id, patient__user=request.user # ownership folded into the fetch
)
return FileResponse(report.scan.open("rb"), as_attachment=True)
get_object_or_404(TestReport, id=..., patient__user=request.user) returns 404 if the report is not this
user's — folding the access check into the fetch (the object-level lesson's pattern), and returning 404,
not 403, so the response does not even reveal that the report exists. Staff access goes through their own
permission-checked path. The rule for a clinic: sensitive files are served by code that checks
permissions, never by a public static/media URL.
The three layers, all present
Nidaan's access control is the layered model from the auth module, and the API is where all three appear together on one request:
- Authenticated —
IsAuthenticated(API) /login_required(views): is anyone logged in. - Permitted — the role may perform the action: patients read via the read-only viewset; staff manage
via the admin and staff views with
DjangoModelPermissions/groups. - Own records only —
get_querysetscoping (and ownership-folded fetches): which records this user sees.
A patient hitting /api/my-reports/ passes all three: logged in, permitted to read reports, and shown only
their own. The breach the design prevents is stopping at layer 2 — "a logged-in patient may read reports"
becoming "may read all reports". For medical data, layer 3 is not optional, and the API is where forgetting
it does the most damage.
Staff access versus patient access
Nidaan has two audiences with very different access, and the design serves both cleanly:
- Staff manage everything through the admin (the operational tool) and staff-only views, gated by
is_staffand group permissions (Receptionists, Technicians) — a staff member with the right role sees across patients, because that is their job. - Patients get the read-only API scoped to themselves — they see only their own data, never the clinic-wide view.
The same models serve both, but through different, separately-secured surfaces: the admin for staff (broad, role-gated), the scoped API for patients (narrow, self-only). Getting this separation right — one data model, two access surfaces with appropriate controls — is the heart of building software that holds real people's sensitive data responsibly.
Check your work
The patient API. A ReadOnlyModelViewSet (patients read, not manage) with IsAuthenticated, and
get_queryset scoped to patient__user=request.user — plus select_related so the API has no N+1 either.
Why scoping is the security. Never trust the client — scoping the queryset means forbidden records are not in the result at all, so another patient's id returns 404; the security is server-side, on every request, not a client filter.
Sensitive uploads. Served by a permission-checking view (ownership folded into get_object_or_404,
returning 404 not 403), never a public media URL — so the file is not reachable or even revealed to
non-owners.
The three layers on the API. Authenticated (IsAuthenticated) → permitted (role) → own-records-only
(get_queryset scope); stopping at layer 2 for medical data is the classic breach.
Two audiences. Staff manage broadly through the admin/staff views (role-gated); patients read only their own data through the scoped API — one model, two separately-secured surfaces.
Practice
- Build a
ReadOnlyModelViewSetfor the patient's appointments withIsAuthenticatedand aget_querysetscoped to the user; confirm an anonymous request is rejected. - Create two patients with data; authenticate as one and confirm you cannot retrieve the other's appointment by id (404) — the scoping as security.
- Add
select_relatedto the API queryset and confirm (query logging) the list is not N+1. - Build a
download_reportview that folds ownership intoget_object_or_404and returns 404 for a report that is not the user's; confirm a public media URL would have leaked it. - Reason through why returning 404 rather than 403 matters for a medical report.
- Map Nidaan's two audiences to their access surfaces (staff → admin/staff views with groups; patients → scoped read-only API) and confirm each of the three layers is present for the patient API.
Official documentation
- DRF — ViewSets and
get_queryset— Scoping the returned data. - DRF — Permissions —
IsAuthenticatedand object-level permissions. - Django —
FileResponse— Serving files through a view.
Next: concurrency safety, tests and going live.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship