Skip to main content

Record-Level Rules: My Sections, My Children – Web API | Lesson 87

Published / updated

Introduction​

Lesson 86 gave each role its endpoints. But a role says nothing about which records a user may touch. Anitha is a teacher, so she could open every section's attendance register, not just her own class.

Parents had the opposite problem: they could see nothing at all. A parent should see their own children's attendance, report cards, and fees, and no other child's.

This lesson adds rules about single records, using ASP.NET Core requirements and handlers.

By the end of this lesson, you will be able to:

  • Explain why role checks alone cannot protect individual records.
  • Write an authorization requirement and a handler that reads the route.
  • Let parents reach only their own children, and teachers only their own sections.
  • Combine role rules and record rules in one policy.
  • Answer 403 for both "not yours" and "does not exist", and say why.

Why this matters​

Changing an id in a URL to see someone else's data is one of the most common security bugs in real APIs. It has a name: IDOR, insecure direct object reference.

In a school, it means one family reading another family's marks, fees, or phone numbers. Every endpoint with an id in its route needs an answer to "is this record yours?".

The concept​

Think of the school office's filing cabinet. Your staff badge gets you into the office: that is the role check. But you may only open the files of your own class: that is the record check, and it needs to know which file you are reaching for.

Example: role rules and record rules​

QuestionNeedsSMS example
Is this user a teacher?only the tokenStaff policy (Lesson 86)
Is she the class teacher of section 20?the token and the record in the routeClassTeacher policy
Is this parent linked to ADM-2017-0003?the token and the databaseStudentRecord policy

Example: requirement and handler​

ASP.NET Core splits a record rule into two pieces:

  • A requirement says what must be true, such as "staff, or a parent of this student". It is a small class that holds settings.
  • A handler checks it. It receives the user and the resource, and calls Succeed when the rule holds. For an endpoint, the resource is the HttpContext, so the handler can read the route.

A policy then just lists its requirements. Controllers still write [Authorize(Policy = Policies.StudentRecord)] and never see the checking code.

Example: the new policies​

PolicyPasses whenOn
StudentRecorda staff role, or a parent linked to the student in the routea student, their guardians, attendance summary, report card
StudentFeesan office role, or a parent linked to the studenta student's fee invoices
ClassTeacherAdmin, Principal, or the section's class teacher this yearattendance register (GET and PUT)
SubjectTeacherAdmin, Principal, or the teacher of the paper's subject in that sectionmarks sheet (GET and PUT)

Teaching from Lesson 86 is gone. It split into ClassTeacher and SubjectTeacher, because taking attendance and entering marks are different duties.

Apply it to the SMS​

Step 1: the facts, from the database​

Each question is one small EXISTS query: is this guardian linked to that student, is this teacher the class teacher of that section, does this teacher teach that paper's subject there?

Show src/SchoolApp.Data/Dapper/DapperAccessRepository.cs
src/SchoolApp.Data/Dapper/DapperAccessRepository.cs
using Dapper;
using Microsoft.Data.SqlClient;
using SchoolApp.Core.Interfaces;

namespace SchoolApp.Data.Dapper;

// Lesson 87: one small EXISTS query per question. Each returns 1 or 0 and reads a handful of rows.
public class DapperAccessRepository : IAccessRepository
{
private readonly SqlConnectionFactory _connections;

public DapperAccessRepository(SqlConnectionFactory connections)
{
_connections = connections;
}

public Task<bool> IsGuardianOfAsync(int guardianId, string admissionNumber, CancellationToken cancellationToken = default) =>
ExistsAsync("""
SELECT 1
FROM dbo.StudentGuardians AS sg
JOIN dbo.Students AS s ON s.Id = sg.StudentId AND s.IsDeleted = 0
JOIN dbo.Guardians AS g ON g.Id = sg.GuardianId AND g.IsDeleted = 0
WHERE sg.GuardianId = @GuardianId AND s.AdmissionNumber = @AdmissionNumber
""", new { GuardianId = guardianId, AdmissionNumber = new DbString { Value = admissionNumber, IsAnsi = true, Length = 15 } },
cancellationToken);

public Task<bool> IsClassTeacherAsync(int teacherId, int sectionId, CancellationToken cancellationToken = default) =>
ExistsAsync("""
SELECT 1
FROM dbo.ClassTeachers AS ct
JOIN dbo.AcademicYears AS y ON y.Id = ct.AcademicYearId AND y.IsCurrent = 1
WHERE ct.TeacherId = @TeacherId AND ct.SectionId = @SectionId
""", new { TeacherId = teacherId, SectionId = sectionId }, cancellationToken);

public Task<bool> TeachesPaperInSectionAsync(int teacherId, int examSubjectId, int sectionId,
CancellationToken cancellationToken = default) =>
ExistsAsync("""
SELECT 1
FROM dbo.ExamSubjects AS es
JOIN dbo.SubjectAllocations AS sa ON sa.SubjectId = es.SubjectId AND sa.SectionId = @SectionId
JOIN dbo.AcademicYears AS y ON y.Id = sa.AcademicYearId AND y.IsCurrent = 1
JOIN dbo.Sections AS sec ON sec.Id = sa.SectionId AND sec.ClassId = es.ClassId
WHERE es.Id = @ExamSubjectId AND sa.TeacherId = @TeacherId
""", new { TeacherId = teacherId, ExamSubjectId = examSubjectId, SectionId = sectionId }, cancellationToken);

private async Task<bool> ExistsAsync(string sql, object parameters, CancellationToken cancellationToken)
{
await using SqlConnection connection = _connections.Create();
return await connection.ExecuteScalarAsync<int?>(new CommandDefinition(
$"SELECT CASE WHEN EXISTS ({sql}) THEN 1 ELSE 0 END;", parameters, cancellationToken: cancellationToken)) == 1;
}
}

Every answer is simply false when a record does not exist. A refusal then never tells the caller whether the student or section is real.

Step 2: the requirements and handlers​

src/SchoolApp.Api/Security/RecordAccess.cs
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using SchoolApp.Core.Interfaces;

namespace SchoolApp.Api.Security;

// Lesson 87: rules about one particular record, not only about the user's role.
// A requirement says what must be true; a handler checks it. For an endpoint, the "resource" the handler
// receives is the HttpContext, so it can read the admission number or section from the route.

// "Staff with these roles, or a parent of the student in the route ({admissionNumber})."
public class StudentAccessRequirement : IAuthorizationRequirement
{
public StudentAccessRequirement(params string[] staffRoles)
{
StaffRoles = staffRoles;
}

public IReadOnlyList<string> StaffRoles { get; }
}

public class StudentAccessHandler : AuthorizationHandler<StudentAccessRequirement>
{
private readonly IAccessRepository _access;

public StudentAccessHandler(IAccessRepository access)
{
_access = access;
}

protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, StudentAccessRequirement requirement)
{
if (requirement.StaffRoles.Any(context.User.IsInRole))
{
context.Succeed(requirement); // staff: any student
return;
}

// A parent: only their own children. The guardian id comes from the token (Lesson 85).
if (context.User.IsInRole(Roles.Parent)
&& int.TryParse(context.User.FindFirstValue(JwtTokenService.GuardianIdClaim), out int guardianId)
&& context.Resource is HttpContext http
&& http.GetRouteValue("admissionNumber") is string admissionNumber
&& await _access.IsGuardianOfAsync(guardianId, admissionNumber, http.RequestAborted))
{
context.Succeed(requirement);
}
// Otherwise: not succeeded, so the answer is 403.
}
}

// Which section rule: the class teacher (attendance), or the teacher of the paper's subject (marks).
public enum SectionDuty
{
ClassTeacher,
SubjectTeacher
}

// "Admin or Principal, or the teacher responsible for this section ({sectionId} in the route or the query)."
public class SectionAccessRequirement : IAuthorizationRequirement
{
public SectionAccessRequirement(SectionDuty duty)
{
Duty = duty;
}

public SectionDuty Duty { get; }
}

public class SectionAccessHandler : AuthorizationHandler<SectionAccessRequirement>
{
private readonly IAccessRepository _access;

public SectionAccessHandler(IAccessRepository access)
{
_access = access;
}

protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, SectionAccessRequirement requirement)
{
if (context.User.IsInRole(Roles.Admin) || context.User.IsInRole(Roles.Principal))
{
context.Succeed(requirement);
return;
}
if (!context.User.IsInRole(Roles.Teacher)
|| !int.TryParse(context.User.FindFirstValue(JwtTokenService.TeacherIdClaim), out int teacherId)
|| context.Resource is not HttpContext http
|| !int.TryParse(http.GetRouteValue("sectionId") as string ?? http.Request.Query["sectionId"], out int sectionId))
{
return;
}

bool allowed = requirement.Duty switch
{
SectionDuty.ClassTeacher => await _access.IsClassTeacherAsync(teacherId, sectionId, http.RequestAborted),
SectionDuty.SubjectTeacher => int.TryParse(http.GetRouteValue("paperId") as string, out int paperId)
&& await _access.TeachesPaperInSectionAsync(teacherId, paperId, sectionId, http.RequestAborted),
_ => false
};
if (allowed)
{
context.Succeed(requirement);
}
}
}

Read StudentAccessHandler from the top:

  1. A staff role in the requirement's list? Succeed: staff may see any student.
  2. Otherwise, a Parent with a guardian_id claim (Lesson 85), an admission number in the route, and a link in StudentGuardians? Succeed.
  3. Anything else: the handler does nothing, and the policy fails with 403.

SectionAccessHandler reads the section from the route (/api/sections/{sectionId}/attendance) or from the query string (/api/exam-papers/{paperId}/marks?sectionId=19).

The handlers are registered as scoped, because they use the request's repository:

Program.cs (the Lesson 87 lines)
builder.Services.AddScoped<IAuthorizationHandler, StudentAccessHandler>();
builder.Services.AddScoped<IAuthorizationHandler, SectionAccessHandler>();

Step 3: the policies​

src/SchoolApp.Api/Security/Policies.cs
using Microsoft.AspNetCore.Authorization;

namespace SchoolApp.Api.Security;

// Lesson 86: the role names, exactly as they are in the Roles table (sms-reference.md).
public static class Roles
{
public const string Admin = "Admin";
public const string Principal = "Principal";
public const string Teacher = "Teacher";
public const string Accountant = "Accountant";
public const string Parent = "Parent";
}

// Lesson 86: named policies. A controller says what kind of user an action needs ("Office"), not a list
// of roles, so changing who may do something is one line here instead of a search through every controller.
public static class Policies
{
public const string Staff = "Staff"; // reading school data: everyone except parents
public const string ManageSchool = "ManageSchool"; // admissions, enrollments, guardians, subjects
public const string Office = "Office"; // fees and the dashboard

// Lesson 87: rules about one record. Teaching (Lesson 86) split into ClassTeacher and SubjectTeacher.
public const string StudentRecord = "StudentRecord"; // staff, or a parent of this student
public const string StudentFees = "StudentFees"; // the office, or a parent of this student
public const string ClassTeacher = "ClassTeacher"; // the section's class teacher: its attendance register
public const string SubjectTeacher = "SubjectTeacher"; // the teacher of this paper's subject in this section

public static void Add(AuthorizationBuilder authorization)
{
authorization
.AddPolicy(Staff, policy => policy.RequireRole(Roles.Admin, Roles.Principal, Roles.Teacher, Roles.Accountant))
.AddPolicy(ManageSchool, policy => policy.RequireRole(Roles.Admin, Roles.Principal))
.AddPolicy(Office, policy => policy.RequireRole(Roles.Admin, Roles.Principal, Roles.Accountant))
.AddPolicy(StudentRecord, policy => policy.AddRequirements(
new StudentAccessRequirement(Roles.Admin, Roles.Principal, Roles.Teacher, Roles.Accountant)))
.AddPolicy(StudentFees, policy => policy.AddRequirements(
new StudentAccessRequirement(Roles.Admin, Roles.Principal, Roles.Accountant)))
.AddPolicy(ClassTeacher, policy => policy.AddRequirements(new SectionAccessRequirement(SectionDuty.ClassTeacher)))
.AddPolicy(SubjectTeacher, policy => policy.AddRequirements(new SectionAccessRequirement(SectionDuty.SubjectTeacher)));
}
}

StudentRecord and StudentFees use the same requirement class with different staff roles. A teacher may read a student's record but not the family's fee history.

Step 4: moving policies from controllers to actions​

StudentsController had Staff on the whole controller (Lesson 86). A policy on the controller must pass for every action, so a parent could never reach GET /api/students/{admissionNumber}. The controller policy moved onto the actions:

ActionPolicy
GET /api/students (the whole list)Staff
GET /api/students/{admissionNumber}StudentRecord
POST, PUT, DELETEManageSchool

GuardiansController and ExamsController changed the same way, because each has one action a parent may use.

Step 5: Rajesh, a parent​

Rajesh is the father of Aarav (ADM-2017-0001) and Advika (ADM-2021-0001):

Output
GET /api/students/ADM-2017-0001 200 Aarav Sharma
GET /api/students/ADM-2017-0003 403 You are signed in, but not allowed to do this.
GET /api/students/ADM-1999-0001 403 (no such student: the same answer)
GET /api/students/ADM-2021-0001/attendance-summary?... 200 Advika: 3 days, 3 present
GET /api/students/ADM-2017-0001/report-cards/1 200 Unit Test 1 report card
GET /api/students/ADM-2017-0001/fee-invoices 200 INV-2026-000001, Paid
GET /api/students 403 (the whole list is for staff)

Changing the admission number in the address gave 403, whether the student was real (Kabir) or not. Rajesh cannot even find out which admission numbers exist.

Step 6: Anitha, a teacher​

Anitha is the class teacher of 10-A and teaches Maths in 10-A and 10-B:

Output
GET /api/sections/19/attendance?date=2026-07-01 200 10-A register (her class)
GET /api/sections/20/attendance?date=2026-07-01 403 10-B register (not her class)
GET /api/exam-papers/20/marks?sectionId=20 200 Mathematics marks for section 20 (she teaches it)
GET /api/exam-papers/19/marks?sectionId=19 403 English marks (she does not teach English)
GET /api/students/ADM-2017-0001/fee-invoices 403 fee history is office data

Step 7: the permission table grows​

The test fixture links test.teacher to teacher 2, Rahul (class teacher of 10-B, Science in 10-A and 10-B), and test.parent to guardian 2, Neha (Aarav's and Advika's mother). The first attempt used teacher 1 and guardian 1, and every API test failed before it started:

Output
SqlException : Cannot insert duplicate key row in object 'dbo.Users' with unique index 'UQ_Users_TeacherId'.
The duplicate key value is (1).

A teacher can have only one user account, and anitha.rao already has teacher 1. The fix was to use other sample people, not to remove the rule.

Show tests/SchoolApp.Tests/Api/AuthorizationTests.cs
tests/SchoolApp.Tests/Api/AuthorizationTests.cs
using System.Net;
using System.Net.Http.Json;
using SchoolApp.Api.Security;

namespace SchoolApp.Tests.Api;

// Lesson 86: who may do what. Each row is one role calling one endpoint, and the status it must get.
// Lesson 87: and which records: the class teacher's section, the subject teacher's paper, a parent's children.
// None of these requests changes SchoolDb: either access is refused (403), or the request is allowed and then
// stops at a rule or a validation error before anything is saved.
[Collection("Api")]
public class AuthorizationTests
{
private readonly ApiFactory _factory;

public AuthorizationTests(ApiFactory factory)
{
_factory = factory;
}

private const string SundayRegister = """{"entries":[{"rollNumber":1,"status":"Present"}]}""";
private const string PastDueDate = """{"classId":10,"dueDate":"2026-01-01"}""";
private const string MarksSheet = """{"entries":[{"rollNumber":1,"marksObtained":50,"isAbsent":false}]}""";

public static TheoryData<string, string, string, string?, HttpStatusCode> Matrix => new()
{
// Reading a student: every staff role, but not a parent (parents get their own rule in Lesson 87).
{ Roles.Teacher, "GET", "/api/students/ADM-2017-0003", null, HttpStatusCode.OK },
{ Roles.Accountant, "GET", "/api/students/ADM-2017-0003", null, HttpStatusCode.OK },
{ Roles.Parent, "GET", "/api/students/ADM-2017-0003", null, HttpStatusCode.Forbidden },

// Admitting a student: ManageSchool. An empty body shows the order: 403 comes before validation.
{ Roles.Teacher, "POST", "/api/students", "{}", HttpStatusCode.Forbidden },
{ Roles.Principal, "POST", "/api/students", "{}", HttpStatusCode.BadRequest },

// Taking attendance: ClassTeacher. Sunday stops the class teacher of 10-B (section 20) at rule 50041.
{ Roles.Accountant, "PUT", "/api/sections/20/attendance?date=2026-07-05", SundayRegister, HttpStatusCode.Forbidden },
{ Roles.Teacher, "PUT", "/api/sections/20/attendance?date=2026-07-05", SundayRegister, HttpStatusCode.UnprocessableEntity },

// Entering marks: SubjectTeacher (Lesson 87). Paper 999 does not exist (50050), but only the principal
// learns that; for a teacher, a paper she does not teach and a paper that does not exist look the same.
{ Roles.Accountant, "PUT", "/api/exam-papers/999/marks?sectionId=19", MarksSheet, HttpStatusCode.Forbidden },
{ Roles.Teacher, "PUT", "/api/exam-papers/999/marks?sectionId=19", MarksSheet, HttpStatusCode.Forbidden },
{ Roles.Principal, "PUT", "/api/exam-papers/999/marks?sectionId=19", MarksSheet, HttpStatusCode.NotFound },
// Paper 21 is Half Yearly Science (he teaches Science in 10-A): allowed, then the incomplete sheet stops at 50044.
{ Roles.Teacher, "PUT", "/api/exam-papers/21/marks?sectionId=19", MarksSheet, HttpStatusCode.UnprocessableEntity },
// Papers 19 and 20 are Half Yearly English and Maths, which he does not teach.
{ Roles.Teacher, "PUT", "/api/exam-papers/19/marks?sectionId=19", MarksSheet, HttpStatusCode.Forbidden },
{ Roles.Teacher, "GET", "/api/exam-papers/20/marks?sectionId=19", null, HttpStatusCode.Forbidden },

// Attendance: ClassTeacher (Lesson 87). He is class teacher of 10-B (section 20), not of 10-A (section 19).
{ Roles.Teacher, "GET", "/api/sections/20/attendance?date=2026-07-01", null, HttpStatusCode.OK },
{ Roles.Teacher, "GET", "/api/sections/19/attendance?date=2026-07-01", null, HttpStatusCode.Forbidden },
{ Roles.Teacher, "PUT", "/api/sections/19/attendance?date=2026-07-05", SundayRegister, HttpStatusCode.Forbidden },

// A parent: only their own children (Lesson 87). Aarav and Advika are hers; Kabir and Meera are not.
{ Roles.Parent, "GET", "/api/students/ADM-2017-0001", null, HttpStatusCode.OK },
{ Roles.Parent, "GET", "/api/students/ADM-2017-0004", null, HttpStatusCode.Forbidden },
{ Roles.Parent, "GET", "/api/students/ADM-1999-0001", null, HttpStatusCode.Forbidden },
{ Roles.Parent, "GET", "/api/students/ADM-2021-0001/attendance-summary?from=2026-07-01&to=2026-07-31", null, HttpStatusCode.OK },
{ Roles.Parent, "GET", "/api/students/ADM-2017-0001/report-cards/1", null, HttpStatusCode.OK },
{ Roles.Parent, "GET", "/api/students/ADM-2017-0004/report-cards/1", null, HttpStatusCode.Forbidden },
{ Roles.Parent, "GET", "/api/students/ADM-2017-0001/fee-invoices", null, HttpStatusCode.OK },
{ Roles.Parent, "GET", "/api/students", null, HttpStatusCode.Forbidden },
// Fee history is office data: a teacher may not see it, even for her own class.
{ Roles.Teacher, "GET", "/api/students/ADM-2017-0001/fee-invoices", null, HttpStatusCode.Forbidden },

// Billing a class: Office. A due date in the past stops the accountant at rule 50016.
{ Roles.Teacher, "POST", "/api/fee-invoices/generate", PastDueDate, HttpStatusCode.Forbidden },
{ Roles.Accountant, "POST", "/api/fee-invoices/generate", PastDueDate, HttpStatusCode.UnprocessableEntity },

// The dashboard: Office.
{ Roles.Teacher, "GET", "/api/dashboard/summary", null, HttpStatusCode.Forbidden },
{ Roles.Accountant, "GET", "/api/dashboard/summary", null, HttpStatusCode.OK },

// Signed in is enough for "who am I?" and for the academic years.
{ Roles.Parent, "GET", "/api/auth/me", null, HttpStatusCode.OK },
{ Roles.Parent, "GET", "/api/academic-years", null, HttpStatusCode.OK },

// [Authorize(Roles = Roles.Admin)] on the diagnostics endpoints.
{ Roles.Principal, "GET", "/api/diagnostics/lifetimes", null, HttpStatusCode.Forbidden },
{ Roles.Admin, "GET", "/api/diagnostics/lifetimes", null, HttpStatusCode.OK },
};

[Theory]
[MemberData(nameof(Matrix))]
public async Task Role_CallingEndpoint_GetsExpectedStatus(string role, string method, string url, string? json,
HttpStatusCode expected)
{
HttpClient client = await _factory.CreateSignedInClientAsync(role);
HttpRequestMessage request = new HttpRequestMessage(new HttpMethod(method), url);
if (json is not null)
{
request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
}

HttpResponseMessage response = await client.SendAsync(request);

Assert.Equal(expected, response.StatusCode);
}

[Fact]
public async Task Forbidden_HasTheApiResponseShape()
{
HttpClient parent = await _factory.CreateSignedInClientAsync(Roles.Parent);

HttpResponseMessage response = await parent.GetAsync("/api/dashboard/summary");

Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
System.Text.Json.JsonElement body = await response.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>();
Assert.Equal("You are signed in, but not allowed to do this.", body.GetProperty("message").GetString());
}
}

One row changed meaning. In Lesson 86, a teacher sending marks for paper 999 got 404; now she gets 403.

For a teacher, a paper she does not teach and a paper that does not exist look the same. The principal still gets 404. The suite has 126 tests, all passing.

Common mistakes​

  1. Checking only the role. Any parent can read any child by changing an id. Fix: a record rule on every endpoint with an id in its route.
  2. 404 for "not yours". A 404 for strangers' records and 403 for real ones reveals which ids exist. Fix: the same 403 for both.
  3. A controller-level policy that blocks a role from one action. Every policy on the controller must pass. Fix: put policies on the actions when they differ.
  4. Trusting an id from the request body or query for "who am I". Users can change it. Fix: take the user's own ids (guardian_id, teacher_id) from the signed token.
  5. Filtering in the app instead of the API. The data still leaves the server. Fix: refuse on the server, before the action runs.

In real projects​

From 16 years of .NET projects

A school's parent app showed report cards at an address ending in the student's admission number. Every check on the server was "is this user a parent?".

A father typed his nephew's number to compare their marks, and it worked. Within a week, a screenshot of another child's marks was in a parents' chat group.

We added a record rule to every endpoint with a student in the route, plus permission tests that try other children's numbers. The same address now answers 403 for anyone who is not that child's guardian.

Best practices​

  • Ask "is this record yours?" on every endpoint with an id in its route.
  • Write record rules as requirements and handlers, and keep controllers to one [Authorize] line.
  • Read who the user is from the token, never from the request body.
  • Answer 403 for missing and forbidden records alike.
  • Test with real links: a parent with two children, a teacher with one class and two subjects.

Recap​

  • Role rules ask "what kind of user?"; record rules ask "is this record yours?".
  • A requirement states the rule; a handler checks it, reading the route through the HttpContext.
  • Parents reach only their own children; teachers only their class register and their subjects' marks.
  • Missing and forbidden records both answer 403, so ids cannot be probed.
  • The permission table now covers records too: 126 tests pass.

Homework​

Library module task: Extend the Lesson 86 library API with GET /api/students/{studentId}/loans. A Librarian may read anyone's loans; a Student only their own. Put the student's id in the token as student_id (Kabir is student 3), and write the rule as a requirement and a handler.

A correct result looks like:

Output
GET /api/students/3/loans (kabir) 200 [{"studentId":3,"title":"Wings of Fire","dueOn":"2026-10-20"}]
GET /api/students/7/loans (kabir) 403
GET /api/students/7/loans (librarian) 200 [{"studentId":7,"title":"The Jungle Book","dueOn":"2026-07-20"}]
GET /api/students/3/loans (no token) 401
Show the solution
library-assignment/087-library-own-loans.cs
// Library homework, Lesson 87: a record-level rule. GET /api/students/{studentId}/loans is allowed for a
// Librarian (any student) or for the student whose id is in the route, and nobody else. The rule is a
// requirement and a handler, like the SMS's StudentAccessHandler.
// Run with: $env:Library__SigningKey = "<at least 32 characters>"; dotnet run library-assignment/087-library-own-loans.cs
#:sdk Microsoft.NET.Sdk.Web
#:package Microsoft.AspNetCore.Authentication.JwtBearer@10.0.11
#:package Microsoft.Extensions.Identity.Core@10.0.11
#:property PublishAot=false
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
string signingKey = builder.Configuration["Library:SigningKey"] ?? "";
if (signingKey.Length < 32)
{
throw new InvalidOperationException("Set Library__SigningKey to at least 32 characters.");
}
SymmetricSecurityKey key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));

builder.Services.AddControllers();
builder.Services.AddSingleton(key);
builder.Services.AddSingleton<LibraryUsers>();
builder.Services.AddSingleton<IAuthorizationHandler, OwnLoansHandler>();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "Library",
ValidAudience = "Library",
IssuerSigningKey = key,
NameClaimType = "name",
RoleClaimType = "role"
};
});
builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
.AddPolicy("OwnLoans", policy => policy.AddRequirements(new OwnLoansRequirement()));

WebApplication app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run("http://localhost:5087");

public record LoginRequest(string UserName, string Password);
public record Loan(int StudentId, string Title, DateOnly DueOn);

// Two users. Kabir is student 3 in SchoolDb, so his token carries student_id 3.
public class LibraryUsers
{
private readonly PasswordHasher<string> _hasher = new PasswordHasher<string>();
private readonly Dictionary<string, (string Hash, string Role, int? StudentId)> _users = new();

public LibraryUsers()
{
_users["librarian"] = (_hasher.HashPassword("librarian", "Library@2026"), "Librarian", null);
_users["kabir"] = (_hasher.HashPassword("kabir", "Student@2026"), "Student", 3);
}

public (string Role, int? StudentId)? Check(string userName, string password) =>
_users.TryGetValue(userName, out var user)
&& _hasher.VerifyHashedPassword(userName, user.Hash, password) != PasswordVerificationResult.Failed
? (user.Role, user.StudentId)
: null;
}

public class OwnLoansRequirement : IAuthorizationRequirement
{
}

// Librarians may see anyone's loans; a student only the loans for the student id in the route.
public class OwnLoansHandler : AuthorizationHandler<OwnLoansRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, OwnLoansRequirement requirement)
{
if (context.User.IsInRole("Librarian"))
{
context.Succeed(requirement);
}
else if (context.Resource is HttpContext http
&& http.GetRouteValue("studentId") is string routeId
&& context.User.FindFirstValue("student_id") == routeId)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}

[ApiController]
[Route("api")]
public class LibraryController : ControllerBase
{
private static readonly List<Loan> Loans =
[
new Loan(3, "Wings of Fire", new DateOnly(2026, 10, 20)),
new Loan(7, "The Jungle Book", new DateOnly(2026, 7, 20))
];

private readonly LibraryUsers _users;
private readonly SymmetricSecurityKey _key;

public LibraryController(LibraryUsers users, SymmetricSecurityKey key)
{
_users = users;
_key = key;
}

[HttpPost("login")]
[AllowAnonymous]
public IActionResult Login(LoginRequest request)
{
var user = _users.Check(request.UserName, request.Password);
if (user is null)
{
return Unauthorized(new { message = "User name or password is wrong." });
}
List<Claim> claims = [new Claim("name", request.UserName), new Claim("role", user.Value.Role)];
if (user.Value.StudentId is int studentId)
{
claims.Add(new Claim("student_id", studentId.ToString()));
}
string token = new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{
Issuer = "Library",
Audience = "Library",
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddMinutes(30),
SigningCredentials = new SigningCredentials(_key, SecurityAlgorithms.HmacSha256)
});
return Ok(new { accessToken = token });
}

[HttpGet("students/{studentId:int}/loans")]
[Authorize(Policy = "OwnLoans")]
public IActionResult LoansOf(int studentId) => Ok(Loans.Where(l => l.StudentId == studentId));
}

The handler compares the route's studentId with the token's student_id. Kabir cannot change his token without breaking its signature.

Quiz​

  1. Why can a role check alone not stop a parent reading another child's report card?

    1. Roles are slow
    2. The role only says 'parent'; it says nothing about which child is in the route
    3. Parents have no role
    4. The token expires
    Show answer

    Answer: B. The role only says 'parent'; it says nothing about which child is in the route A record rule must compare the user with the record.

  2. In an endpoint's authorization handler, what is context.Resource?

    1. The database
    2. The HttpContext, so the handler can read the route
    3. The controller
    4. The token
    Show answer

    Answer: B. The HttpContext, so the handler can read the route StudentAccessHandler reads admissionNumber from the route through it.

  3. What did Rajesh get for a student who does not exist?

    1. 404
    2. 403, the same as for another family's child
    3. 500
    4. 200 with no data
    Show answer

    Answer: B. 403, the same as for another family's child He cannot use the answers to find out which admission numbers exist.

  4. Why did Staff move from StudentsController onto its actions?

    1. For speed
    2. Every controller-level policy must pass, so parents could never reach GET by admission number
    3. Policies cannot be on controllers
    4. To fix a build error
    Show answer

    Answer: B. Every controller-level policy must pass, so parents could never reach GET by admission number Policies that differ per action belong on the actions.

  5. Where does the handler get the parent's guardian id?

    1. From the request body
    2. From the signed token's guardian_id claim
    3. From the query string
    4. From a cookie the app sets
    Show answer

    Answer: B. From the signed token's guardian_id claim Anything in the request can be changed; the signed token cannot.

Interview questions​

Questions are grouped by experience level. Click a question to see a model answer. All questions: Security interview questions.

Fresher (0-2 yrs)​

F What is IDOR (insecure direct object reference)? concept

A bug where an API checks that a user may use an endpoint but not whether the record in the request belongs to them. Changing an id in the URL then shows someone else's data, such as a parent opening another child's report card by changing the admission number.

See the example · Permalink

F What is the difference between a role rule and a record rule? concept

A role rule needs only the token: "is this user a teacher?". A record rule also needs the record being reached: "is she the class teacher of section 20?" or "is this parent linked to ADM-2017-0003?". Record rules usually read the route and ask the database.

See the example · Permalink

F Should an API answer 404 or 403 when a parent asks for a student who is not theirs? concept

403, and the same 403 when the student does not exist at all. If real records gave 403 and missing ones 404, the answers would reveal which ids exist. The SMS answers 403 for both, and only users allowed to see a record ever learn whether it exists.

See the example · Permalink

Mid (3-5 yrs)​

M What are an authorization requirement and an authorization handler in ASP.NET Core? coding

A requirement (IAuthorizationRequirement) is a small class that states a rule and holds its settings, such as which staff roles count. A handler (AuthorizationHandler<TRequirement>) checks it and calls context.Succeed when the rule holds. A policy lists requirements, so controllers only name the policy.

See the example · Permalink

M How can an authorization handler know which record a request is about? coding

For endpoints, context.Resource is the HttpContext, so the handler can read route values (such as admissionNumber or sectionId) and the query string. The SMS handlers read them, then ask the database whether the user is linked to that record. For rules that need a loaded object, you can instead call IAuthorizationService.AuthorizeAsync(User, resource, policy) inside the action.

See the example · Permalink

M Why should a handler take the user's own ids from the token and not from the request? concept

Anything in the request (body, query, headers other than the token) can be changed by the user. The token is signed, so its claims, such as guardian_id or teacher_id, cannot be changed without breaking the signature. The SMS compares the route's record with the ids from the token.

See the example · Permalink

Senior (6-9 yrs)​

S A parent reports seeing another child's marks by changing a number in the app's address. What do you do? scenario

Confirm and fix the endpoint first: add a record rule that checks the parent is linked to the student in the route. Then search every endpoint with an id in its route for the same gap, add permission tests that try other families' ids, and check logs for who reached records they should not have. Report the exposure as the school's policy requires.

How to answer: Cover the immediate fix, the systematic search for the same bug, tests that prevent it returning, and the investigation and reporting of the leak.

See the example · Permalink

S Record checks add a database query to many requests. How do you keep them fast and correct? design

Keep each check a tiny EXISTS query on indexed links (StudentGuardians, ClassTeachers, SubjectAllocations). Put small, stable facts in the token at sign-in, such as the user's teacher or guardian id, but not lists that change often. Cache answers per user for a short time if needed, and invalidate when links change. Measure: in the SMS each check reads only a few rows.

How to answer: Balance speed against freshness: what goes in the token, what is queried, what is cached, and how changes reach the checks.

See the example · Permalink

Architect / Lead (10+ yrs)​

A List endpoints (such as all invoices) must also show each user only their records. How would you design that across a large API? design

Record rules on single-record endpoints are not enough for lists: the query itself must filter. Build the filter from the user (for example, only students linked to this guardian) in one shared place, such as a data-access layer that always adds it, or database row-level security with SESSION_CONTEXT set per request. Test lists with users who own nothing, one record, and many records.

How to answer: Separate single-record checks from list filtering, compare doing it in the data layer with database row-level security, and explain how to make it impossible to forget.

See the example · Permalink

Resources​

  • Code for this lesson: git tag lesson-87-record-access (code repository coming soon)
  • Files: src/SchoolApp.Api/Security/RecordAccess.cs, src/SchoolApp.Api/Security/Policies.cs, src/SchoolApp.Data/Dapper/DapperAccessRepository.cs, tests/SchoolApp.Tests/Api/AuthorizationTests.cs, library-assignment/087-library-own-loans.cs
  • Video: coming soon

What's next​

The API now knows who is calling and what they may touch. Next, it records who changed what: every update stamped with the user, and the audit log filled in. Lesson 88: Recording who changed what

Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.