cswork / libraryOpen practice workspace

Articles

Django REST Framework Interview Questions: Serializers, Object Permissions, and Safe List Endpoints

Practice Django REST Framework interview questions through a project API: serializers, object permissions, list filtering, safe creates, and PATCH tests.

A Django REST Framework interview question about permissions often hides a list-endpoint leak. You add has_object_permission() and verify that a user cannot retrieve a colleague's project by ID, yet GET /projects/ still includes that project. The official DRF permissions guide explains why: generic views run object checks when they retrieve one object, but do not automatically apply object permission checks to every row in a list. A safe answer separates queryset visibility, object actions, and create-time ownership.

We will audit one original multi-user API rather than recite serializer definitions. For related practice, Review getEvents endpoint for readability, performance, scalability, security prompts a similarly concrete endpoint review. Its record is practice material, not a reported DRF interview question. All framework behavior below comes from official DRF documentation; the project data and test cases are original.

The interview fixture: a shared project API

Assume two organizations, North and South. North has projects N1 and N2; South has S1. Alice belongs to North and owns N1. Bob belongs to North and owns N2. Cara belongs to South and owns S1. A project has id, organization_id, owner_id, name, visibility, and secret_note. Members of an organization may list and read its projects; only the owner may edit one. New projects must be assigned to the authenticated user's organization and user ID, regardless of client input.

The security invariant is concrete: Alice must never receive S1 in a list, retrieve it by ID, or create a new row owned by Cara. She may see Bob's N2 on a list but not modify it. Whether a cross-organization lookup returns 403 or 404 is a product and threat-model choice; if the object is excluded by get_queryset(), a generic retrieve commonly returns 404. The important test is that no protected data appears.

This fixture creates three distinct attack paths: an unfiltered list, a direct object lookup, and a forged create body. Passing one path does not establish the other two. The distinction is exactly what makes the topic stronger than a generic “what is a serializer?” interview answer.

Question 1: What belongs in get_queryset()?

The DRF generic-view documentation says get_queryset() supplies the base queryset for list views and detail lookups, and can depend on the requesting user. In this fixture, the base queryset should constrain visibility to the authenticated user's organization membership before pagination or serialization. If users can belong to multiple organizations, filter by the authorized organization set rather than trusting an arbitrary organization_id query parameter. A query parameter can narrow an already-authorized set; it must not define authorization by itself.

One simplified sketch is Project.objects.filter(organization__members=request.user) with appropriate membership and active-status conditions for the real schema. That expression is illustrative: the actual join may duplicate projects when memberships have multiple matching rows, so examine whether distinct() or a different predicate is needed. The critical property is that S1 cannot enter Alice's queryset. A serializer that omits secret_note does not repair a list whose project IDs and names already leak across organizations.

Use self.get_queryset() in an overridden get_object() or list method. The DRF docs warn against directly evaluating self.queryset and reusing cached results across requests. Apply filtering and ordering before paginating, and make the order deterministic so a client does not receive unstable pages while projects are created.

Question 2: When does has_object_permission() run?

DRF's object-permission rules explain that generic views call the check during .get_object(). For an update to N2, Alice's scoped queryset may find it, then the owner-only object permission rejects the edit. For S1, a scoped queryset can hide the row before the object check. If you override get_object() in a custom view, call self.check_object_permissions(request, obj) explicitly after retrieving the object; otherwise the override may bypass the check.

A candidate should say what the permission class actually implements. Most built-in permission classes do not magically enforce your owner_id rule; a custom has_object_permission() must encode it, and view-level has_permission() may still restrict unauthenticated users or disallowed actions. Read-only access for North members and write access for the owner is a policy decision for this fixture, not a DRF default.

The surprising limitation is the list route. DRF does not iterate through a list queryset and call object permissions for every row, for performance reasons. The list must be filtered appropriately; otherwise has_object_permission() passing your retrieve test gives false confidence. State this limitation before proposing any test suite.

Question 3: Why does create need a different ownership check?

There is no existing project object for has_object_permission() to inspect during POST /projects/. DRF's permissions guide explicitly notes that object-level checks are not applied to create in the same way. The server must decide whether this user may create a project and which organization and owner values may be saved. Put policy checks in the appropriate view or serializer path, such as perform_create() for a viewset, and derive protected ownership fields from authenticated server context.

For example, after validating ordinary fields, perform_create() can call serializer.save(owner=request.user, organization=authorized_org) when authorized_org was established by the server. Merely setting owner_id to read-only protects that field from client writes, but it does not decide which organization a user is allowed to create into. If the URL includes /organizations/{id}/projects/, verify membership for that {id} on the create path. Never treat a client-supplied organization ID as a membership proof.

Try the malicious body { "name": "Spy", "owner_id": cara_id, "organization_id": south_id } as Alice. The desired result is a rejection or a newly created North/Alice project, according to the API contract, never a South/Cara row. Follow the response with a database read, because a sanitized response could conceal a wrongly persisted owner.

Question 4: What does a serializer validate, and what does it not?

The DRF serializer guide covers field validation, object-level validate(), read-only fields, and explicit create/update behavior. A ModelSerializer can validate name length, allowed visibility values, and a cross-field rule such as “private projects require a nonempty owner.” It can define what fields enter the API representation. It is not, by itself, the complete object-visibility policy for a list endpoint.

Use an explicit field list for the case. If secret_note is internal, omit it from read serializers or use a separate authorized detail serializer; do not rely on a UI hiding the field. Mark owner and organization read-only in the input schema or use separate create and response serializers. DRF documents a nuance: Meta.read_only_fields does not override a field explicitly declared on the serializer class; set read_only=True on that declaration itself. Verify the resulting serializer fields rather than assuming the option took effect.

Do not turn every authorization failure into a validation error. “Is this name syntactically valid?” and “May Alice create in South?” are different questions, even if implementation boundaries can be shared. A clean answer states where the authenticated request context enters validation or save and where the view enforces access.

Question 5: How does PATCH change validation?

For a partial update, DRF's serializer documentation says only supplied fields appear in the data given to validate(). If a rule needs both visibility and secret_note, reading data['visibility'] unconditionally can fail or incorrectly reject a valid PATCH. Use data.get('visibility', self.instance.visibility) when an existing instance is present, and reason separately about an explicit null versus an omitted field.

Suppose Alice owns N1 and sends { "visibility": "public" }. If the business rule forbids publishing a project that has an internal note, validation must examine the resulting state, including the existing note. Bob's attempt to send the same PATCH to N1 should fail object authorization before field validation is treated as sufficient. Cara's request to N1 should never gain access merely because she knows the ID. Tests should cover each of those paths.

Nested writes are another interview trap. DRF documents that writable nested create/update behavior must be defined explicitly because the meaning of missing, null, replacement, and deletion is ambiguous. If a project request includes collaborators, decide whether the request is allowed to add them and what a PATCH does to omitted collaborators. An automatically generated serializer cannot infer the product policy.

A complete list/detail/create test matrix

One happy-path test per route is too weak for this fixture. Build a matrix over identity, route, and expected visibility. The cells below focus on security behavior; add input validation and query-count checks afterward.

RequestExpected outcomeFailure exposed
Alice lists projectsN1 and N2, never S1Unscoped queryset or unsafe filter parameter
Alice retrieves N2Allowed under this fixture's member-read policyDetail policy inconsistent with list
Alice patches N2Denied; N2 unchangedMissing owner object check
Alice retrieves S1No protected row or note disclosedCross-organization IDOR
Alice creates with Cara/South IDsRejected or server-owned North/Alice rowMass assignment or unvalidated organization
Anonymous user listsDenied under this fixture's authenticated policyMissing view-level permission

Use APIClient.force_authenticate() or the project's supported authentication setup to make identity explicit. After a denied write, assert database values are unchanged. After a list, inspect every returned ID, not just the count: an unrelated extra row can keep the count constant. For a cross-tenant retrieve, do not assert 403 if the implementation intentionally scopes the queryset to yield 404; assert the intended contract and absence of data leakage.

Safe list endpoints also need performance checks

Security and query shape meet at serialization. If each returned project serializes owner.username and a collaborators collection, a page of projects can trigger extra database queries. The generic-view guide recommends using select_related or prefetch_related in get_queryset() when serializer relations would create N+1 behavior. That optimization must preserve the visibility filter and not expand the response field set.

Compare query counts for a one-row page and a larger page under Alice's identity. Inspect the SQL and row cardinality; do not just time one local request. Ensure pagination happens after the authorization predicate. A prefetch_related that loads many collaborators may be wasteful if the list response only needs a count or an owner name, so match the fetch plan to the serializer actually used for that route.

get_serializer_class() can provide a compact list serializer and a richer detail serializer, but the list serializer cannot be a substitute for queryset scoping. Even if it hides secret_note, it may leak the existence of S1 through an ID, name, count, or pagination metadata. The invariant remains “unauthorized projects are absent from the candidate queryset.”

How to answer this interview case succinctly

Say: “First I define the visibility policy for list, retrieve, update, and create separately. get_queryset() excludes projects outside the user's organizations before list pagination or detail lookup. A custom object permission checks owner-only edits on the retrieved object. The create path verifies the target organization and stamps owner from the authenticated user. The serializer validates allowed fields and response shape, including PATCH semantics, but does not replace those access controls.”

Then present two failing tests: Alice's list contains S1 despite a passing direct-access denial; Alice forges a South organization_id on create. Those tests expose the two common gaps that a single has_object_permission() check misses. Finish with one performance check for relation serialization. This is a fuller answer than listing DRF class names because it demonstrates what each class does at the relevant route.

Sources and further reading