{"id":233,"date":"2026-11-02T12:00:00","date_gmt":"2026-11-02T12:00:00","guid":{"rendered":"https:\/\/rezachegini.com\/?p=233"},"modified":"2026-11-02T20:28:21","modified_gmt":"2026-11-02T20:28:21","slug":"building-a-job-board-api-part-5-who-is-allowed-to-do-what","status":"publish","type":"post","link":"https:\/\/rezachegini.com\/?p=233","title":{"rendered":"Building a Job Board API, Part 5: Who Is Allowed to Do What"},"content":{"rendered":"\n<h2 class=\"wp-block-heading\">Where we are<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">At the end of Phase 1, I listed the gaps I left open on purpose. The API worked, but it was not safe yet:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Any company could post a job, even one that was not verified.<\/li>\n\n\n\n<li>Any logged-in user could edit any job.<\/li>\n\n\n\n<li>Any logged-in user could see the applicants of any job.<\/li>\n\n\n\n<li>Any logged-in user could read any application.<\/li>\n\n\n\n<li>A company could not change an application&#8217;s status at all.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">This article closes all of them. I replace my small inline role checks with four permission classes, and I add the missing status endpoint.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Two kinds of permission<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In DRF, a permission class is a small class that answers one question: is this request allowed? It has two methods you can override:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>has_permission(request, view)<\/code> is a view-level check. It runs for every request, before anything is loaded from the database. It answers &#8220;who are you?&#8221;<\/li>\n\n\n\n<li><code>has_object_permission(request, view, obj)<\/code> is an object-level check. It runs only after the view has loaded a specific row. It answers &#8220;is this thing yours?&#8221;<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Both return True by default. If any check returns False, DRF stops and returns 403 Forbidden.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1440\" height=\"926\" src=\"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-5-permission-diagram.png\" alt=\"Anatomy of a permission check diagram\" class=\"wp-image-232\" srcset=\"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-5-permission-diagram.png 1440w, https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-5-permission-diagram-300x193.png 300w, https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-5-permission-diagram-1024x658.png 1024w, https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-5-permission-diagram-768x494.png 768w\" sizes=\"auto, (max-width: 1440px) 100vw, 1440px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The most important detail is in the highlighted box. Object permissions only run when the view calls <code>get_object()<\/code> or <code>get_queryset()<\/code>. A plain list view never loads one object, so <code>has_object_permission<\/code> never runs there. Remember that; it explains one of my design choices below.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 1: Role permissions<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">All four classes live in a new file, <code>jobs\/permissions.py<\/code>. The first two only look at who the caller is, so they only override <code>has_permission<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">IsVerifiedCompany<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>class IsVerifiedCompany(BasePermission):\n    message = \"Only company accounts can do this.\"\n\n    def has_permission(self, request, view):\n        user = request.user\n        if not (user and user.is_authenticated and user.is_company):\n            return False\n        company = getattr(user, \"company\", None)\n        if company is None:\n            return False\n        if not company.is_verified:\n            self.message = \"Your company account is not verified.\"\n            return False\n        return True<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>message<\/code> is the text DRF puts in the 403 response. Without it, users get a generic &#8220;You do not have permission to perform this action.&#8221;<\/li>\n\n\n\n<li>The order in the first <code>if<\/code> matters. An anonymous caller is an <code>AnonymousUser<\/code>, and it has no <code>is_company<\/code> attribute. Checking <code>is_authenticated<\/code> first uses Python&#8217;s short-circuit and, so the attribute is only read for real users. (In practice my views also put <code>IsAuthenticated<\/code> first, see Step 3.)<\/li>\n\n\n\n<li><code>getattr(user, \"company\", None)<\/code> turns a missing company row into a clean 403 instead of a crash. The reverse relation raises <code>RelatedObjectDoesNotExist<\/code>, which is a subclass of both <code>Company.DoesNotExist<\/code> and <code>AttributeError<\/code>, so <code>getattr<\/code> catches it and returns <code>None<\/code>.<\/li>\n\n\n\n<li>For an unverified company, I change <code>self.message<\/code> to something more useful: &#8220;Your company account is not verified.&#8221; DRF reads <code>message<\/code> from the instance after the check fails, so setting it inside the method works.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">IsCandidate<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>class IsCandidate(BasePermission):\n    message = \"Only candidate accounts can do this.\"\n\n    def has_permission(self, request, view):\n        user = request.user\n        return bool(user and user.is_authenticated and user.is_candidate)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">One line. <code>and<\/code> stops at the first False, so again, anonymous users never reach <code>is_candidate<\/code>. The last expression is already a bool, but I keep the <code>bool()<\/code> to be safe.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 2: Ownership permissions<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The next two classes ask a different question: is this object yours? So they override <code>has_object_permission<\/code>, and their <code>has_permission<\/code> stays at the default True.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">IsJobOwner<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>class IsJobOwner(BasePermission):\n    message = \"Only the company that posted this job can do this.\"\n\n    def has_object_permission(self, request, view, obj):\n        user = request.user\n        if not (user and user.is_authenticated):\n            return False\n        company = getattr(user, \"company\", None)\n        if company is None:\n            return False\n        if isinstance(obj, Job):\n            return obj.company_id == company.id\n        if isinstance(obj, Application):\n            return obj.job.company_id == company.id\n        return False<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>I use this one class on two kinds of object. Editing a job or listing its applicants gives me a <code>Job<\/code>. Changing an application&#8217;s status gives me an <code>Application<\/code>; then ownership means &#8220;the company that owns the job this application belongs to&#8221;.<\/li>\n\n\n\n<li>I chose <code>isinstance<\/code> over a shorter trick like <code>getattr(obj, \"job\", obj)<\/code>. That trick would silently treat an unexpected object as a job. I would rather return False for anything I do not understand.<\/li>\n\n\n\n<li><code>job.company_id<\/code> is the raw foreign-key column stored on the job row. Comparing it with <code>company.id<\/code> needs no extra query, unlike <code>job.company.id<\/code>, which would load the Company row first.<\/li>\n\n\n\n<li>For a candidate, <code>company<\/code> is <code>None<\/code>, so the answer is False and the response is 403.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">IsApplicationOwner<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>class IsApplicationOwner(BasePermission):\n    message = \"You don't have access to this application.\"\n\n    def has_object_permission(self, request, view, obj):\n        user = request.user\n        if not (user and user.is_authenticated):\n            return False\n        if user.is_candidate:\n            profile = getattr(user, \"candidate_profile\", None)\n            return bool(profile is not None and obj.candidate_id == profile.id)\n        company = getattr(user, \"company\", None)\n        if company is not None:\n            return obj.job.company_id == company.id\n        return False<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">An application has two people who should be able to read it:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>The candidate who wrote it.<\/li>\n\n\n\n<li>The company that owns the job.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The method checks the candidate first and returns True early. Then it checks the company. Everyone else gets False.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>IsJobOwner<\/code>. Same object, different action, different rule.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 3: A different rule for each action<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A permission class does nothing until a view uses it. Each of my views needs different rules for different actions, so I override <code>get_permissions()<\/code> and read <code>self.action<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>class JobViewSet(...):\n    def get_permissions(self):\n        if self.action == \"create\":\n            return [IsAuthenticated(), IsVerifiedCompany()]\n        if self.action in (\"update\", \"partial_update\", \"destroy\", \"applications\"):\n            return [IsAuthenticated(), IsJobOwner()]\n        return [IsAuthenticated()]<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>DRF calls <code>get_permissions()<\/code> on every request, and all the returned checks must pass.<\/li>\n\n\n\n<li>Notice the brackets: <code>IsVerifiedCompany()<\/code> is an instance of the class, not the class itself.<\/li>\n\n\n\n<li><code>IsAuthenticated()<\/code> comes first in every branch. That way, a request with no token fails with 401 (&#8220;who are you?&#8221;) instead of 403 (&#8220;I know who you are, and no&#8221;).<\/li>\n\n\n\n<li>&#8220;update&#8221; is PUT and &#8220;partial_update&#8221; is PATCH. Both call <code>get_object()<\/code>, so <code>IsJobOwner<\/code> runs.<\/li>\n\n\n\n<li>&#8220;applications&#8221; is my custom action that lists a job&#8217;s applicants. Its first line is <code>self.get_object()<\/code>, which is exactly what makes the object check run on a list-style endpoint. The list itself is then built from that job.<\/li>\n\n\n\n<li><code>list<\/code> and <code>retrieve<\/code> fall 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.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The application view follows the same pattern:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>class ApplicationViewSet(...):\n    def get_permissions(self):\n        if self.action == \"create\":\n            return [IsAuthenticated(), IsCandidate()]\n        if self.action == \"retrieve\":\n            return [IsAuthenticated(), IsApplicationOwner()]\n        if self.action == \"set_status\":\n            return [IsAuthenticated(), IsJobOwner()]\n        return [IsAuthenticated()]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Look at &#8220;set_status&#8221;. <code>self.action<\/code> holds the method name of an action, not its URL. The URL is <code>\/status\/<\/code> (see Step 4), but the action is named <code>set_status<\/code>. Confusing those two cost me a 404, below.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The inline checks are gone<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In Phase 1, my views had small <code>if<\/code> checks like &#8220;if the user is not a company, raise PermissionDenied&#8221;. I deleted them all. One example:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def perform_create(self, serializer):\n    serializer.save(company=self.request.user.company)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>IsVerifiedCompany<\/code> already guarantees a verified company with a real Company row, so this line is safe without a local check.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 4: The status endpoint<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Now a company can move an application through the hiring pipeline: <code>PATCH \/api\/applications\/{id}\/status\/<\/code> with a body like <code>{\"status\": \"reviewing\"}<\/code>. The allowed values come from the model: applied, reviewing, interview, rejected, accepted.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">A serializer with one field<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>class ApplicationStatusSerializer(serializers.ModelSerializer):\n    class Meta:\n        model = Application\n        fields = [\"status\"]\n        extra_kwargs = {\"status\": {\"required\": True}}<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Only one field. The company can change status and nothing else.<\/li>\n\n\n\n<li><code>ModelSerializer<\/code> builds <code>status<\/code> as a choice field from the model, so a value like &#8220;hired&#8221; gets a 400: &#8220;\\&#8221;hired\\&#8221; is not a valid choice.&#8221;<\/li>\n\n\n\n<li>In my main <code>ApplicationSerializer<\/code>, <code>status<\/code> is read-only, because a candidate must not set it. Having two serializers for the same model, one per action, is normal in DRF.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">The extra_kwargs trap<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">That last line, <code>extra_kwargs = {\"status\": {\"required\": True}}<\/code>, was not in my first version. Without it, PATCH with an empty body <code>{}<\/code> returned 200 and the unchanged application. A silent no-op that looks like success.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The reason: <code>ModelSerializer<\/code> makes a field optional when the model field has a default. And <code>Application.status<\/code> has <code>default=\"applied\"<\/code> (a new application starts as applied). So &#8220;not required&#8221; came from the model, not from anything I wrote in the serializer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>extra_kwargs<\/code> changes options on an automatic field without writing the field again by hand, so the choice validation still comes from the model.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One honest note: my first explanation of this bug was wrong. I thought it was about <code>partial=True<\/code>. It was not. PATCH being partial is fine; the problem was only that this one field was not required.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The action<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>@action(detail=True, methods=[\"patch\"], url_path=\"status\")\ndef set_status(self, request, pk=None):\n    application = self.get_object()\n    serializer = ApplicationStatusSerializer(application, data=request.data)\n    serializer.is_valid(raise_exception=True)\n    serializer.save()\n    return Response(ApplicationSerializer(application).data)   # 200<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>methods=[\"patch\"]<\/code> because one field of an existing object changes. GET or POST returns 405.<\/li>\n\n\n\n<li><code>url_path=\"status\"<\/code> sets the URL. Without it, DRF uses the method name, and the URL would be <code>\/set_status\/<\/code>, with an underscore.<\/li>\n\n\n\n<li><code>self.get_object()<\/code> loads the application and runs <code>IsJobOwner<\/code>.<\/li>\n\n\n\n<li>Passing the existing object plus data tells the serializer &#8220;update this&#8221;. Without the object, <code>save()<\/code> would try to create a new application.<\/li>\n\n\n\n<li>The response uses the full <code>ApplicationSerializer<\/code>, so the client sees the whole updated application, not just <code>{\"status\": ...}<\/code>.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>save()<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Problems I hit: names that fail silently<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">1. One missing letter: <code>get_permission<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The cause was one letter. I had named the method <code>get_permission<\/code>, without the s. DRF only calls <code>get_permissions<\/code>. It never complained, because from DRF&#8217;s point of view, I simply had not overridden anything. Every view fell back to the default, which in my settings is just <code>IsAuthenticated<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">How did I prove it? With one line in the Django shell:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>'get_permissions' in JobViewSet.__dict__\n# False<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>__dict__<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">2. Acme was never verified.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">After fix 1, my verified test company Acme got 403 &#8220;Your company account is not verified.&#8221; The code was right; my test data was wrong. When I created the users in Phase 1, I set <code>is_verified<\/code> on something else (or only meant to). The database said False.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Company.objects.filter(name=\"Acme\").update(is_verified=True)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">I also corrected my Phase 1 notes, which said Acme was verified.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">3. The status URL returned 404.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">My first version had <code>url_path=\"set-status\"<\/code>. So the real URL was <code>\/set-status\/<\/code>, and my request to <code>\/status\/<\/code> was a 404. I found it by listing the URLs the router actually created:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>python manage.py show_urls | grep applications\n# \/api\/applications\/{pk}\/set-status\/\n# \/api\/applications\/{pk}\/status\/      &lt;- after the fix<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">4. The status response was too small.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The first version returned <code>ApplicationStatusSerializer(...)<\/code>, so the response was only <code>{\"status\": \"reviewing\"}<\/code>. A client would have to make a second request to see the application. Swapping in <code>ApplicationSerializer(application).data<\/code> fixed it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">5. An empty body returned 200.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is the <code>extra_kwargs<\/code> story from Step 4.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">6. Command &#8216;token&#8217; not found in a new terminal.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">My small bash helper for getting tokens only existed in the terminal where I defined it. So I moved it into a file, <code>helpers.sh<\/code>, and load it in each new shell with:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>. .\/helpers.sh<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>.\/runserver.sh      # helper: runserver 8000, PYTHONDONTWRITEBYTECODE=1\n\ntoken() {           # helper: prints the access token for a user\n  name=$1\n  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',''))\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>.<\/code> is source: it runs the file in the current shell, so the functions stay. Running it with <code>bash<\/code> would define them in a child shell, and they would disappear when it exits.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The pattern<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Look at the list of silent bugs so far:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>get_permission<\/code> instead of <code>get_permissions<\/code> (this part)<\/li>\n\n\n\n<li><code>url_path=\"set-status\"<\/code> instead of &#8220;status&#8221; (this part)<\/li>\n\n\n\n<li>&#8220;candidateprofile&#8221; instead of &#8220;candidate_profile&#8221; (Phase 1)<\/li>\n\n\n\n<li><code>def application<\/code> instead of <code>def applications<\/code> (Phase 1)<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Every one of them is a name that doesn&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is now my working rule for Phase 2: after writing anything the framework calls by name, I check with <code>__dict__<\/code> that my override is really there, and with <code>show_urls<\/code> that the URL is really the one I think it is.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Did it work?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">First, I proved that every method is defined where I think it is:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>all('get_permissions' in c.__dict__ for c in (JobViewSet, ApplicationViewSet))     # True\nall('has_object_permission' in c.__dict__ for c in (IsJobOwner, IsApplicationOwner)) # True<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Then the real requests:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Acme posts a job: 201. Globex posts a job: 403, &#8220;Your company account is not verified.&#8221; dana posts a job: 403, &#8220;Only company accounts can do this.&#8221; No token posting a job: 401.<\/li>\n\n\n\n<li>Acme applies to a job with a fake job ID: 403, not 400. The permission now runs before validation.<\/li>\n\n\n\n<li>Acme edits its own job: 200. Globex edits Acme&#8217;s job: 403. Initech edits its own job: 200, because ownership does not need verification.<\/li>\n\n\n\n<li>The applicants of job 1: Acme sees 2. Initech and sam get 403.<\/li>\n\n\n\n<li>Reading application 3: sam and Acme get 200. dana and Initech get 403.<\/li>\n\n\n\n<li>Changing the status: Acme sets it to &#8220;reviewing&#8221;: 200 with the full application. sam tries to set his own application&#8217;s status: 403. dana: 403. Initech, who does not own the job: 403. Globex: 403. No token: 401.<\/li>\n\n\n\n<li>Bad status bodies: &#8220;hired&#8221; gives 400 (not a valid choice). <code>{}<\/code> and a misspelled key give 400 (&#8220;This field is required.&#8221;).<\/li>\n\n\n\n<li>Afterwards, sam reads his application and sees &#8220;reviewing&#8221;. The company&#8217;s change is visible to the candidate it affects.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The goal of Phase 2 has two halves: &#8220;wrong user types get 403&#8221; and &#8220;match scores return correctly&#8221;. The first half is done and tested. The second half is the next part.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What I learned<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Two kinds of permission. &#8220;Who are you?&#8221; runs on every request. &#8220;Is this yours?&#8221; only runs when the view loads an object. If the second one never runs, check <code>get_object()<\/code> before blaming the class.<\/li>\n\n\n\n<li>Put rules in one place. A permission class is reusable, easy to test, and runs before validation. Inline <code>if<\/code>s in views were none of those.<\/li>\n\n\n\n<li>401 before 403. Put <code>IsAuthenticated<\/code> first, so a missing token is reported as a missing token.<\/li>\n\n\n\n<li>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.<\/li>\n\n\n\n<li>Model defaults change serializer behaviour. A field with a default becomes optional, and an empty request can return 200 while doing nothing.<\/li>\n\n\n\n<li>The framework never warns you about a wrong name. It just ignores your code. Check that your overrides exist (<code>__dict__<\/code>) and that your URLs exist (<code>show_urls<\/code>).<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">What comes next<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In the next part, I build the second half of Phase 2: the skill match score. It compares a job&#8217;s required skills with a candidate&#8217;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.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Where we are I am a junior DevOps and Cloud engineer looking for my first role in SRE, DevOps or Cloud, and&hellip;<\/p>\n","protected":false},"author":2,"featured_media":231,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_rezaops_part":"5","_rezaops_linkedin_url":"https:\/\/www.linkedin.com\/pulse\/building-job-board-api-part-5-who-allowed-do-what-reza-chegini-aigfc\/","footnotes":""},"categories":[10,116],"tags":[8,111,191,118,82,25,112],"series":[117],"class_list":["post-233","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","category-django","tag-devops","tag-django","tag-drf","tag-job-board","tag-permissions","tag-python","tag-rest-api","series-job-board-api"],"jetpack_featured_media_url":"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-5-cover.png","_links":{"self":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/233","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=233"}],"version-history":[{"count":1,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/233\/revisions"}],"predecessor-version":[{"id":234,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/233\/revisions\/234"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/media\/231"}],"wp:attachment":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=233"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=233"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=233"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=233"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}