Skip to main content

Roles and Policies: Who May Do What (403) – Web API | Lesson 86

Published / updated

Introduction​

Since Lesson 85, every caller must sign in. But a signed-in parent could still read every student, record a fee payment, or take attendance. The API knew who was calling, but not what they may do.

The SMS has five roles: Admin, Principal, Teacher, Accountant, and Parent. This lesson gives each endpoint the right set of roles, using policies.

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

  • Explain role-based access control, and why 403 is different from 401.
  • Define named authorization policies with RequireRole.
  • Protect controllers and actions with [Authorize(Policy = ...)].
  • Combine a controller-level policy with a stricter one on an action.
  • Prove the whole permission table with one data-driven test.

Why this matters​

Most real data leaks are not clever attacks. They are signed-in users reaching data that their role should never see.

A school's rules are clear: a teacher marks attendance, the accountant handles money, the principal sees everything. Those rules must live in the API, because anyone can call the API directly, without the app's buttons.

The concept​

Think of key cards in a school building. Every card proves who you are, but each card opens different doors. A policy is a door's rule: "staff cards only" or "accounts and principal only".

Example: roles and policies​

A role is a group of users, stored in the UserRoles table and sent as role claims in the token. A policy is a named rule that an endpoint asks for. The SMS has four:

PolicyRolesUsed for
StaffAdmin, Principal, Teacher, Accountantreading school data
ManageSchoolAdmin, Principaladmitting, updating, and removing students; enrolling; guardians; subjects
TeachingAdmin, Principal, Teacherattendance registers and marks sheets
OfficeAdmin, Principal, Accountantinvoices, payments, billing, the outstanding report, the dashboard

Parents are in no policy yet. Lesson 87 gives them their own rule: "only my own children".

Admin and Principal appear in every policy. That is a choice, not a rule of ASP.NET Core: a school could decide that the principal reads fee data but never records a payment. Then Office would split into two policies, and only this file would change.

Example: why policies, not role lists​

You could write [Authorize(Roles = "Admin,Principal,Teacher")] on every action. Then the day the school adds a "Coordinator" role, you search every controller for every list.

With policies, the controller says what kind of user it needs (Teaching). Who counts as "teaching" is decided in one place.

Example: 401 or 403​

StatusMeaningSMS message
401 UnauthorizedNo valid token: the API does not know who you areSign in first: send a valid access token.
403 ForbiddenValid token, but your role may not do thisYou are signed in, but not allowed to do this.

The app reacts differently to each. A 401 should send the user to the sign-in screen. A 403 should show a "not allowed" message, because signing in again will not help.

Example: how ASP.NET Core decides​

For every request, the authorization middleware follows the same steps:

  1. Find the endpoint. Routing has already chosen the controller action.
  2. Collect its rules. Every [Authorize] on the controller and on the action, or the fallback policy if there are none, or nothing at all if the endpoint says [AllowAnonymous].
  3. Check the user. Authentication (Lesson 85) has already turned the token into User, with its role claims.
  4. Decide. All rules pass: the action runs. No signed-in user: challenge, which answers 401. Signed in but a rule fails: forbid, which answers 403.

Only then does model binding read the body and validation check it. That order matters, as Step 4 shows.

Apply it to the SMS​

Step 1: roles and policies in one file​

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 ("Teaching"), 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 Teaching = "Teaching"; // attendance and marks
public const string Office = "Office"; // fees and the dashboard

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(Teaching, policy => policy.RequireRole(Roles.Admin, Roles.Principal, Roles.Teacher))
.AddPolicy(Office, policy => policy.RequireRole(Roles.Admin, Roles.Principal, Roles.Accountant));
}
}

The role names match the Roles table exactly, because they arrive in the token from there (Lesson 85). Each policy uses RequireRole: the user needs any one of the listed roles.

Step 2: register the policies​

Program.cs (the Lesson 86 lines)
// Lesson 86: and the named policies (Staff, ManageSchool, Teaching, Office) in Security/Policies.cs.
Policies.Add(builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build()));

The fallback policy from Lesson 85 stays. An endpoint without its own policy still needs a signed-in user.

Step 3: protect the controllers​

A policy on the controller applies to every action. A policy on an action adds to it: the user must pass both. Here is how StudentsController starts:

StudentsController.cs (the Lesson 86 lines)
[ApiController]
[Authorize(Policy = Policies.Staff)] // Lesson 86: school staff may read students; changes need ManageSchool
[Route("api/students")]
public class StudentsController : ControllerBase
{
// GET /api/students and GET /api/students/{admissionNumber}: Staff, from the controller.

[Authorize(Policy = Policies.ManageSchool)]
[HttpPost]
public async Task<ActionResult<ApiResponse<StudentDto>>> Admit(CreateStudentRequest request, ...)

[Authorize(Policy = Policies.ManageSchool)]
[HttpPut("{admissionNumber}")]
...
[Authorize(Policy = Policies.ManageSchool)]
[HttpDelete("{admissionNumber}")]
...
}

Every controller got the same treatment:

ControllerOn the controllerOn some actions
Students, Guardians, EnrollmentsStaffManageSchool on POST, PUT, DELETE
Classes, TeachersStaff—
ExamsStaffTeaching on marks sheets (GET and PUT)
Attendance—Teaching on registers; Staff on the two reports
FeeInvoices, DashboardOffice—
FeeBilling—Staff on fee structures; Office on the rest
Subjects, AcademicYears—ManageSchool on adding a subject
Diagnostics[Authorize(Roles = Roles.Admin)]—

The diagnostics endpoints show the shortcut [Authorize(Roles = ...)], fine for one rare case. Everything else goes through a policy.

Example: choosing a policy for a new endpoint​

Every new endpoint in the rest of this course answers four questions, in this order:

  1. Must strangers use it? Only sign-in and public school details. Then [AllowAnonymous], and nothing else.
  2. Does it change data? Then it needs the strict policy for that area: ManageSchool, Teaching, or Office. Reading usually needs Staff.
  3. Is it about money or the whole school's numbers? Then Office, even for reading.
  4. Is it about one family's data? Then the parent rule from Lesson 87.

If no answer fits, the fallback policy still asks for a signed-in user. That is the safe default, not the goal: every endpoint should say on purpose who may use it.

A short rule helps in code reviews too. Any new [HttpPost], [HttpPut], or [HttpDelete] without an [Authorize(Policy = ...)] near it is a question to ask before the code is merged.

Step 4: the same requests, three roles​

Signed in as the sample users from Lesson 85:

Output
rajesh.sharma (Parent) GET /api/students/ADM-2017-0003 403
{"status":"error","message":"You are signed in, but not allowed to do this.","traceId":"00-8e94…"}

anitha.rao (Teacher) GET /api/students/ADM-2017-0003 200
{"status":"success","message":"Student found.","data":{"id":3,"admissionNumber":"ADM-2017-0003","fullName":"Kabir Singh", ...}}

anitha.rao (Teacher) POST /api/students {} 403
principal POST /api/students {} 400 One or more fields are invalid.

accounts (Accountant) PUT /api/sections/19/attendance?date=2026-07-05 403
anitha.rao (Teacher) PUT /api/sections/19/attendance?date=2026-07-05 422 2026-07-05 is a Sunday, not a school day.

anitha.rao (Teacher) GET /api/dashboard/summary 403
accounts (Accountant) GET /api/dashboard/summary 200 Dashboard for 2026-10-08.

Look at the two POST /api/students lines. The teacher got 403 even though the body was empty: authorization runs before validation. A user who may not do something never learns what a valid request looks like.

The teacher's attendance request passed authorization and then stopped at a school rule (Sunday). Permission and business rules are separate checks, in that order.

Step 5: the permission table as a test​

The test fixture now creates one test user per role. One data-driven test then checks the whole table:

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.
// 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: Teaching. Sunday stops an allowed teacher at rule 50041.
{ Roles.Accountant, "PUT", "/api/sections/19/attendance?date=2026-07-05", SundayRegister, HttpStatusCode.Forbidden },
{ Roles.Teacher, "PUT", "/api/sections/19/attendance?date=2026-07-05", SundayRegister, HttpStatusCode.UnprocessableEntity },

// Entering marks: Teaching. Paper 999 does not exist (50050).
{ 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.NotFound },

// 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());
}
}

Each row is a role, a request, and the expected status. None of them changes SchoolDb: every allowed request is chosen to stop at a rule or a validation error before anything is saved. The suite has 110 tests, all passing.

When the school changes a permission, this table is the first place to change. It shows the rule and proves it at the same time.

Example: when a user's role changes​

Roles travel inside the token, and the API does not look them up again on each request. So when the office makes a teacher an Accountant, her current token still says "Teacher" until it expires, up to 60 minutes later.

That is the trade-off of tokens: no database lookup per request, but changes arrive late. The SMS accepts it because roles change rarely. Systems that must react at once keep tokens shorter, or check a list of revoked tokens on each request (an interview question for this lesson).

Common mistakes​

  1. Hiding buttons instead of protecting endpoints. Anyone can call the API without the app. Fix: [Authorize] on the server for every rule.
  2. Role lists copied into every controller. One forgotten copy is a security hole. Fix: named policies in one file.
  3. Returning 401 for "not allowed". The app sends a signed-in user back to the sign-in screen. Fix: 401 for no valid token, 403 for no permission.
  4. Role names that do not match the token. "admin" and "Admin" are different strings. Fix: constants that match the Roles table.
  5. Testing only the happy path. Nobody notices a role that can do too much. Fix: a test row for each role that must be refused.

In real projects​

From 16 years of .NET projects

A school app hid the "Fees" menu from teachers, and everyone thought fees were protected. The endpoints behind the menu had no role checks at all.

A curious teacher opened the browser's developer tools, copied a request, and changed the invoice number. She could see every family's fee balance.

We moved every permission into policies on the API and added a permission test table like this lesson's. The menu still hides fees from teachers; now the API refuses them too.

Best practices​

  • Enforce every permission on the server, never only in the app.
  • Name policies after what users do ("Teaching", "Office"), and keep them in one file.
  • Keep the fallback policy, so a new endpoint is never open by accident.
  • Return 401 and 403 correctly, in the same response shape as every other error.
  • Test every role against every sensitive endpoint, especially the refusals.

Recap​

  • Four policies (Staff, ManageSchool, Teaching, Office) map the SMS roles to what they may do.
  • [Authorize(Policy = ...)] on a controller applies to every action; on an action it adds a stricter rule.
  • A forbidden request gets 403 before its body is validated.
  • Permission checks come first, then school rules such as "not on a Sunday".
  • One data-driven test proves the whole permission table: 110 tests pass.

Homework​

Library module task: Extend the Lesson 85 library API with two users: librarian (role Librarian) and kabir (role Student). Any signed-in user may list books. Only a Librarian may issue one, through a policy named IssueBooks.

A correct result looks like:

Output
GET /api/books (no token) 401
GET /api/books (kabir) 200 ["Wings of Fire","The Jungle Book","Malgudi Days"]
POST /api/book-issues (kabir) 403
POST /api/book-issues (librarian) 200 {"message":"9780143039655 issued to student 6 by librarian."}
Show the solution
library-assignment/086-library-roles.cs
// Library homework, Lesson 86: two roles. A Student may sign in and read the catalogue; only a Librarian may
// issue books (policy "IssueBooks"). Builds on the Lesson 85 homework: same sign-in, now with role claims.
// Run with: $env:Library__SigningKey = "<at least 32 characters>"; dotnet run library-assignment/086-library-roles.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.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "Library",
ValidAudience = "Library",
IssuerSigningKey = key,
NameClaimType = "name",
RoleClaimType = "role" // so RequireRole reads the "role" claims
};
});
builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
.AddPolicy("IssueBooks", policy => policy.RequireRole("Librarian"));

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

public record LoginRequest(string UserName, string Password);
public record IssueRequest(string Isbn, int StudentId);

// Two users, each with one role. Passwords are hashed once at startup.
public class LibraryUsers
{
private readonly PasswordHasher<string> _hasher = new PasswordHasher<string>();
private readonly Dictionary<string, (string Hash, string Role)> _users = new();

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

// The user's role when the password is right; otherwise null.
public string? RoleOf(string userName, string password) =>
_users.TryGetValue(userName, out var user)
&& _hasher.VerifyHashedPassword(userName, user.Hash, password) != PasswordVerificationResult.Failed
? user.Role
: null;
}

[ApiController]
[Route("api")]
public class LibraryController : ControllerBase
{
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)
{
string? role = _users.RoleOf(request.UserName, request.Password);
if (role is null)
{
return Unauthorized(new { message = "User name or password is wrong." });
}
string token = new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{
Issuer = "Library",
Audience = "Library",
Subject = new ClaimsIdentity([new Claim("name", request.UserName), new Claim("role", role)]),
Expires = DateTime.UtcNow.AddMinutes(30),
SigningCredentials = new SigningCredentials(_key, SecurityAlgorithms.HmacSha256)
});
return Ok(new { accessToken = token, role });
}

// Any signed-in user (the fallback policy).
[HttpGet("books")]
public IActionResult Books() => Ok(new[] { "Wings of Fire", "The Jungle Book", "Malgudi Days" });

[HttpPost("book-issues")]
[Authorize(Policy = "IssueBooks")]
public IActionResult Issue(IssueRequest request) =>
Ok(new { message = $"{request.Isbn} issued to student {request.StudentId} by {User.Identity!.Name}." });
}

The token now carries a role claim, and RoleClaimType = "role" tells ASP.NET Core where to find it.

Quiz​

  1. A signed-in teacher calls an endpoint that needs the Office policy. What is the answer?

    1. 401
    2. 403
    3. 404
    4. 200
    Show answer

    Answer: B. 403 The token is valid, so 401 is wrong; the role is not allowed, so 403.

  2. Why use named policies instead of role lists on every action?

    1. They are faster
    2. Who counts as 'Teaching' is decided in one place, so a change is one line
    3. Roles cannot be used on actions
    4. Policies need no token
    Show answer

    Answer: B. Who counts as 'Teaching' is decided in one place, so a change is one line A copied role list is easy to forget when roles change.

  3. StudentsController has Staff, and Admit has ManageSchool. What does a user need to admit a student?

    1. Only Staff
    2. Only ManageSchool
    3. Both: every policy on the controller and the action must pass
    4. Either one
    Show answer

    Answer: C. Both: every policy on the controller and the action must pass Admin and Principal pass both.

  4. Why did a teacher get 403, not 400, for an empty admission request?

    1. The body was too small
    2. Authorization runs before model validation
    3. Teachers cannot send JSON
    4. A bug
    Show answer

    Answer: B. Authorization runs before model validation A refused user never sees validation details.

  5. Why is hiding the Fees menu from teachers not enough?

    1. Menus are slow
    2. Anyone can call the API directly, without the app's menus
    3. Teachers can change the menu
    4. It is enough
    Show answer

    Answer: B. Anyone can call the API directly, without the app's menus Permissions must be enforced on the server.

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 role-based access control? concept

Users belong to roles (in the SMS: Admin, Principal, Teacher, Accountant, Parent), and permissions are given to roles instead of to each user. The API checks the user's roles, sent as role claims in the token, before running an action.

See the example · Permalink

F A signed-in user calls an endpoint their role may not use. What status should the API return? concept

403 Forbidden: the API knows who they are, but they are not allowed. 401 is only for a missing, invalid, or expired token. The app treats them differently: 401 means "sign in", 403 means "not allowed".

See the example · Permalink

F Why is hiding a button in the app not enough to protect a feature? concept

Anyone can call the API directly, with a copied token and a tool such as curl or the browser's developer tools. Every permission must be checked on the server with [Authorize]; hiding buttons is only for a tidy screen.

See the example · Permalink

Mid (3-5 yrs)​

M What is the advantage of named policies over [Authorize(Roles = "...")] lists? design

The controller says what kind of user it needs (such as Teaching), and the roles for each policy are defined in one place. Adding a role or changing a permission is one line, instead of a search through every controller where one forgotten copy becomes a security hole.

See the example · Permalink

M A controller has [Authorize(Policy = "Staff")] and one action has [Authorize(Policy = "ManageSchool")]. Who may call the action? coding

Only users who pass both policies: every authorization attribute on the controller and on the action must pass. In the SMS, that is Admin and Principal, who are in both Staff and ManageSchool.

See the example · Permalink

M In what order does ASP.NET Core run authorization and model validation, and why does it matter? concept

Authorization runs first, then model binding and validation. A refused user gets 403 even with an invalid body, so they never learn what a valid request looks like. In the SMS, a teacher posting an empty admission got 403, while the principal got 400 with the field errors.

See the example · Permalink

Senior (6-9 yrs)​

S The office changes a teacher into an accountant, but for a while she can still take attendance and cannot open fees. Why, and what are the options? scenario

Her roles are inside her access token, and the API does not look them up again on each request, so the change only arrives with her next token (up to 60 minutes in the SMS). Options: accept the delay and ask her to sign in again; shorten token lifetimes; or check a revocation list or the user's current roles on each request, at the cost of a database or cache lookup.

How to answer: Explain claims in self-contained tokens, then weigh the options by how fast changes must apply and what each costs.

See the example · Permalink

S How do you make sure no role can do more than it should, as an API grows? design

Secure by default with a fallback policy, named policies in one file, and a data-driven test table with one row per role and sensitive endpoint, including the refusals. In the SMS, each row is a role, a request, and the expected status, chosen so no row changes data. Review new write endpoints for their policy before merging.

How to answer: Combine design (defaults and named policies), automated proof (the permission table), and process (reviews), and explain why refusals need tests as much as successes.

See the example · Permalink

Architect / Lead (10+ yrs)​

A Schools want to define their own roles and permissions without a new release. How would you design it? design

Move from fixed roles to permissions: store Permissions, RolePermissions, and UserRoles per school, and make endpoints require permissions (such as Attendance.Take) instead of roles. A custom authorization policy provider can create a policy per permission name, and a handler checks the user's permissions, loaded at sign-in into the token or cached per user. Keep a few built-in roles as templates, audit every change to permissions, and decide how fast changes apply.

How to answer: Explain the move from roles to permissions, how ASP.NET Core can check them dynamically, where the data lives per school, and how changes propagate and are audited.

See the example · Permalink

Resources​

  • Code for this lesson: git tag lesson-86-roles-policies (code repository coming soon)
  • Files: src/SchoolApp.Api/Security/Policies.cs, src/SchoolApp.Api/Controllers/StudentsController.cs, src/SchoolApp.Api/Program.cs, tests/SchoolApp.Tests/Api/AuthorizationTests.cs, library-assignment/086-library-roles.cs
  • Video: coming soon

What's next​

Roles decide what kind of data a user may touch. Next, the API checks which records: a teacher sees only her own sections, and a parent only their own children. Lesson 87: Record-level rules

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