$ cat job-board-api/part-05.md
Building a Job Board API, Part 5: Who Is Allowed to Do What

Where we are
I am a junior DevOps and Cloud engineer looking for my first role in SRE, DevOps or Cloud, and I am building this project in public, step by step. In my last series, I finished Phase 1 of my Job Board API: a Django REST API where companies post jobs and candidates apply.
At the end of Phase 1, I listed the gaps I left open on purpose. The API worked, but it was not safe yet:
- Any company could post a job, even one that was not verified.
- Any logged-in user could edit any job.
- Any logged-in user could see the applicants of any job.
- Any logged-in user could read any application.
- A company could not change an application’s status at all.
This article closes all of them. I replace my small inline role checks with four permission classes, and I add the missing status endpoint.
And once again, the most interesting part is the bugs. Three of them had the same cause: a name that the framework looks up by string, typed slightly wrong, failing silently.
Two kinds of permission
In DRF, a permission class is a small class that answers one question: is this request allowed? It has two methods you can override:
has_permission(request, view)is a view-level check. It runs for every request, before anything is loaded from the database. It answers “who are you?”has_object_permission(request, view, obj)is an object-level check. It runs only after the view has loaded a specific row. It answers “is this thing yours?”
Both return True by default. If any check returns False, DRF stops and returns 403 Forbidden.

The most important detail is in the highlighted box. Object permissions only run when the view calls get_object() or get_queryset(). A plain list view never loads one object, so has_object_permission never runs there. Remember that; it explains one of my design choices below.
Step 1: Role permissions
All four classes live in a new file, jobs/permissions.py. The first two only look at who the caller is, so they only override has_permission.
IsVerifiedCompany
class IsVerifiedCompany(BasePermission):
message = "Only company accounts can do this."
def has_permission(self, request, view):
user = request.user
if not (user and user.is_authenticated and user.is_company):
return False
company = getattr(user, "company", None)
if company is None:
return False
if not company.is_verified:
self.message = "Your company account is not verified."
return False
return True
messageis the text DRF puts in the 403 response. Without it, users get a generic “You do not have permission to perform this action.”- The order in the first
ifmatters. An anonymous caller is anAnonymousUser, and it has nois_companyattribute. Checkingis_authenticatedfirst uses Python’s short-circuit and, so the attribute is only read for real users. (In practice my views also putIsAuthenticatedfirst, see Step 3.) getattr(user, "company", None)turns a missing company row into a clean 403 instead of a crash. The reverse relation raisesRelatedObjectDoesNotExist, which is a subclass of bothCompany.DoesNotExistandAttributeError, sogetattrcatches it and returnsNone.- For an unverified company, I change
self.messageto something more useful: “Your company account is not verified.” DRF readsmessagefrom the instance after the check fails, so setting it inside the method works.
IsCandidate
class IsCandidate(BasePermission):
message = "Only candidate accounts can do this."
def has_permission(self, request, view):
user = request.user
return bool(user and user.is_authenticated and user.is_candidate)
One line. and stops at the first False, so again, anonymous users never reach is_candidate. The last expression is already a bool, but I keep the bool() to be safe.
Step 2: Ownership permissions
The next two classes ask a different question: is this object yours? So they override has_object_permission, and their has_permission stays at the default True.
IsJobOwner
class IsJobOwner(BasePermission):
message = "Only the company that posted this job can do this."
def has_object_permission(self, request, view, obj):
user = request.user
if not (user and user.is_authenticated):
return False
company = getattr(user, "company", None)
if company is None:
return False
if isinstance(obj, Job):
return obj.company_id == company.id
if isinstance(obj, Application):
return obj.job.company_id == company.id
return False
- I use this one class on two kinds of object. Editing a job or listing its applicants gives me a
Job. Changing an application’s status gives me anApplication; then ownership means “the company that owns the job this application belongs to”. - I chose
isinstanceover a shorter trick likegetattr(obj, "job", obj). That trick would silently treat an unexpected object as a job. I would rather return False for anything I do not understand. job.company_idis the raw foreign-key column stored on the job row. Comparing it withcompany.idneeds no extra query, unlikejob.company.id, which would load the Company row first.- For a candidate,
companyisNone, so the answer is False and the response is 403.
One design decision here: ownership does not require verification. An unverified company can still edit its own job or change a status; it just cannot post new jobs. Verification is about trust from candidates. Ownership is about access to your own data. Two different questions, two different classes.
IsApplicationOwner
class IsApplicationOwner(BasePermission):
message = "You don't have access to this application."
def has_object_permission(self, request, view, obj):
user = request.user
if not (user and user.is_authenticated):
return False
if user.is_candidate:
profile = getattr(user, "candidate_profile", None)
return bool(profile is not None and obj.candidate_id == profile.id)
company = getattr(user, "company", None)
if company is not None:
return obj.job.company_id == company.id
return False
An application has two people who should be able to read it:
- The candidate who wrote it.
- The company that owns the job.
The method checks the candidate first and returns True early. Then it checks the company. Everyone else gets False.
I use this class for reading only. A candidate must be able to read their application, but must not be able to change its status. So the status endpoint does not use this class. It uses IsJobOwner. Same object, different action, different rule.
Step 3: A different rule for each action
A permission class does nothing until a view uses it. Each of my views needs different rules for different actions, so I override get_permissions() and read self.action:
class JobViewSet(...):
def get_permissions(self):
if self.action == "create":
return [IsAuthenticated(), IsVerifiedCompany()]
if self.action in ("update", "partial_update", "destroy", "applications"):
return [IsAuthenticated(), IsJobOwner()]
return [IsAuthenticated()]
- DRF calls
get_permissions()on every request, and all the returned checks must pass. - Notice the brackets:
IsVerifiedCompany()is an instance of the class, not the class itself. IsAuthenticated()comes first in every branch. That way, a request with no token fails with 401 (“who are you?”) instead of 403 (“I know who you are, and no”).- “update” is PUT and “partial_update” is PATCH. Both call
get_object(), soIsJobOwnerruns. - “applications” is my custom action that lists a job’s applicants. Its first line is
self.get_object(), which is exactly what makes the object check run on a list-style endpoint. The list itself is then built from that job. listandretrievefall back to the global rule: any logged-in user can browse jobs. That is on purpose; a job board where nobody can read jobs is not useful.
The application view follows the same pattern:
class ApplicationViewSet(...):
def get_permissions(self):
if self.action == "create":
return [IsAuthenticated(), IsCandidate()]
if self.action == "retrieve":
return [IsAuthenticated(), IsApplicationOwner()]
if self.action == "set_status":
return [IsAuthenticated(), IsJobOwner()]
return [IsAuthenticated()]
Look at “set_status”. self.action holds the method name of an action, not its URL. The URL is /status/ (see Step 4), but the action is named set_status. Confusing those two cost me a 404, below.
The inline checks are gone
In Phase 1, my views had small if checks like “if the user is not a company, raise PermissionDenied”. I deleted them all. One example:
def perform_create(self, serializer):
serializer.save(company=self.request.user.company)
IsVerifiedCompany already guarantees a verified company with a real Company row, so this line is safe without a local check.
There is a nice side effect. Permission classes run before validation. In Phase 1, a company sending an application got a 400, because the serializer complained about a field first. Now it gets a 403: wrong role, and DRF says so before looking at the body.
Step 4: The status endpoint
Now a company can move an application through the hiring pipeline: PATCH /api/applications/{id}/status/ with a body like {"status": "reviewing"}. The allowed values come from the model: applied, reviewing, interview, rejected, accepted.
A serializer with one field
class ApplicationStatusSerializer(serializers.ModelSerializer):
class Meta:
model = Application
fields = ["status"]
extra_kwargs = {"status": {"required": True}}
- Only one field. The company can change status and nothing else.
ModelSerializerbuildsstatusas a choice field from the model, so a value like “hired” gets a 400: “\”hired\” is not a valid choice.”- In my main
ApplicationSerializer,statusis read-only, because a candidate must not set it. Having two serializers for the same model, one per action, is normal in DRF.
The extra_kwargs trap
That last line, extra_kwargs = {"status": {"required": True}}, was not in my first version. Without it, PATCH with an empty body {} returned 200 and the unchanged application. A silent no-op that looks like success.
The reason: ModelSerializer makes a field optional when the model field has a default. And Application.status has default="applied" (a new application starts as applied). So “not required” came from the model, not from anything I wrote in the serializer.
extra_kwargs changes options on an automatic field without writing the field again by hand, so the choice validation still comes from the model.
One honest note: my first explanation of this bug was wrong. I thought it was about partial=True. It was not. PATCH being partial is fine; the problem was only that this one field was not required.
The action
@action(detail=True, methods=["patch"], url_path="status")
def set_status(self, request, pk=None):
application = self.get_object()
serializer = ApplicationStatusSerializer(application, data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(ApplicationSerializer(application).data) # 200
methods=["patch"]because one field of an existing object changes. GET or POST returns 405.url_path="status"sets the URL. Without it, DRF uses the method name, and the URL would be/set_status/, with an underscore.self.get_object()loads the application and runsIsJobOwner.- Passing the existing object plus data tells the serializer “update this”. Without the object,
save()would try to create a new application. - The response uses the full
ApplicationSerializer, so the client sees the whole updated application, not just{"status": ...}.
In Phase 3, this is exactly where I will put a message on a queue, so a background worker can email the candidate when their status changes. The endpoint does not change; something gets added after save().
Problems I hit: names that fail silently
1. One missing letter: get_permission.
After writing the code, I tested it. Globex, an unverified company, posted a job and got 201 Created. My new permission classes were doing nothing at all.
The cause was one letter. I had named the method get_permission, without the s. DRF only calls get_permissions. It never complained, because from DRF’s point of view, I simply had not overridden anything. Every view fell back to the default, which in my settings is just IsAuthenticated.
How did I prove it? With one line in the Django shell:
'get_permissions' in JobViewSet.__dict__
# False
__dict__ only holds what is defined directly on a class, not what it inherits. False meant my override did not exist under that name. After renaming, the same check returned True, and Globex started getting 403.
2. Acme was never verified.
After fix 1, my verified test company Acme got 403 “Your company account is not verified.” The code was right; my test data was wrong. When I created the users in Phase 1, I set is_verified on something else (or only meant to). The database said False.
Company.objects.filter(name="Acme").update(is_verified=True)
I also corrected my Phase 1 notes, which said Acme was verified.
3. The status URL returned 404.
My first version had url_path="set-status". So the real URL was /set-status/, and my request to /status/ was a 404. I found it by listing the URLs the router actually created:
python manage.py show_urls | grep applications
# /api/applications/{pk}/set-status/
# /api/applications/{pk}/status/ <- after the fix
4. The status response was too small.
The first version returned ApplicationStatusSerializer(...), so the response was only {"status": "reviewing"}. A client would have to make a second request to see the application. Swapping in ApplicationSerializer(application).data fixed it.
5. An empty body returned 200.
This is the extra_kwargs story from Step 4.
6. Command ‘token’ not found in a new terminal.
My small bash helper for getting tokens only existed in the terminal where I defined it. So I moved it into a file, helpers.sh, and load it in each new shell with:
. ./helpers.sh
./runserver.sh # helper: runserver 8000, PYTHONDONTWRITEBYTECODE=1
token() { # helper: prints the access token for a user
name=$1
curl -s -X POST localhost:8000/api/auth/login/ -H "Content-Type: application/json" -d "{\"username\":\"$name\",\"password\":\"pass1234\"}" | python3 -c "import sys, json; print(json.load(sys.stdin).get('access',''))"
}
. is source: it runs the file in the current shell, so the functions stay. Running it with bash would define them in a child shell, and they would disappear when it exits.
The pattern
Look at the list of silent bugs so far:
get_permissioninstead ofget_permissions(this part)url_path="set-status"instead of “status” (this part)- “candidateprofile” instead of “candidate_profile” (Phase 1)
def applicationinstead ofdef applications(Phase 1)
Every one of them is a name that doesn’t match what the framework expects. None of them raised an error. The code loaded, the server ran, the tests I had at the time passed.
This is now my working rule for Phase 2: after writing anything the framework calls by name, I check with __dict__ that my override is really there, and with show_urls that the URL is really the one I think it is.
Did it work?
My test users: Acme (verified company, owns job 1), Globex (unverified company), Initech (unverified company, owns its own job), sam (candidate, wrote application 3), dana (candidate).
First, I proved that every method is defined where I think it is:
all('get_permissions' in c.__dict__ for c in (JobViewSet, ApplicationViewSet)) # True
all('has_object_permission' in c.__dict__ for c in (IsJobOwner, IsApplicationOwner)) # True
Then the real requests:
- Acme posts a job: 201. Globex posts a job: 403, “Your company account is not verified.” dana posts a job: 403, “Only company accounts can do this.” No token posting a job: 401.
- Acme applies to a job with a fake job ID: 403, not 400. The permission now runs before validation.
- Acme edits its own job: 200. Globex edits Acme’s job: 403. Initech edits its own job: 200, because ownership does not need verification.
- The applicants of job 1: Acme sees 2. Initech and sam get 403.
- Reading application 3: sam and Acme get 200. dana and Initech get 403.
- Changing the status: Acme sets it to “reviewing”: 200 with the full application. sam tries to set his own application’s status: 403. dana: 403. Initech, who does not own the job: 403. Globex: 403. No token: 401.
- Bad status bodies: “hired” gives 400 (not a valid choice).
{}and a misspelled key give 400 (“This field is required.”). - Afterwards, sam reads his application and sees “reviewing”. The company’s change is visible to the candidate it affects.
The goal of Phase 2 has two halves: “wrong user types get 403” and “match scores return correctly”. The first half is done and tested. The second half is the next part.
What I learned
- Two kinds of permission. “Who are you?” runs on every request. “Is this yours?” only runs when the view loads an object. If the second one never runs, check
get_object()before blaming the class. - Put rules in one place. A permission class is reusable, easy to test, and runs before validation. Inline
ifs in views were none of those. - 401 before 403. Put
IsAuthenticatedfirst, so a missing token is reported as a missing token. - Reading and changing are different rights. The candidate can read the application but only the company can change its status, so they use different classes on the same object.
- Model defaults change serializer behaviour. A field with a default becomes optional, and an empty request can return 200 while doing nothing.
- The framework never warns you about a wrong name. It just ignores your code. Check that your overrides exist (
__dict__) and that your URLs exist (show_urls).
What comes next
In the next part, I build the second half of Phase 2: the skill match score. It compares a job’s required skills with a candidate’s skills and returns a score, so candidates can see how well they fit a job, and companies can sort applicants by it. The access rules from this part decide who is allowed to see those scores.