Prototype illustrative role assignments project

This commit is contained in:
ivan-pelly
2026-02-19 07:19:50 -08:00
parent 9d9a416d1c
commit f178add2be
34 changed files with 2118 additions and 0 deletions
+3
View File
@@ -1,2 +1,5 @@
.DS_Store .DS_Store
.env .env
/prototype/RolesAssignments/.vs
/prototype/RolesAssignments/bin
/prototype/RolesAssignments/obj
@@ -0,0 +1,97 @@
// =============================================================================
// ProgressEntryAuthorizationHandler.cs
// =============================================================================
// This is the SECOND LAYER of authorization. It handles the question:
// "Can this user edit/delete THIS SPECIFIC progress entry?"
//
// By the time this handler is called, the controller has ALREADY verified that
// the user has student-level access (using StudentAuthorizationHandler).
// This handler adds the extra ownership check.
//
// THE RULE:
// - Teachers (PrimaryTeacher / TemporaryCoverage) can edit or delete
// ANY entry for their assigned students.
// - Paraeducators can ONLY edit or delete entries that THEY created.
// (The CreatedByUserId on the entry must match their user ID.)
// - Supervisors cannot edit or delete anything. Since they don't match any
// case below, they get an implicit deny.
//
// WHY TWO LAYERS?
// ---------------
// Imagine a paraeducator (Mr. Daniels) assigned to Student 101. He made an
// entry yesterday. The primary teacher (Ms. Rivera) also made one today.
//
// When Mr. Daniels tries to edit HIS entry:
// Layer 1 (Student): "Is Mr. Daniels assigned to Student 101?" → Yes ✓
// Layer 2 (Entry): "Did Mr. Daniels create this entry?" → Yes ✓ → ALLOWED
//
// When Mr. Daniels tries to edit MS. RIVERA'S entry:
// Layer 1 (Student): "Is Mr. Daniels assigned to Student 101?" → Yes ✓
// Layer 2 (Entry): "Did Mr. Daniels create this entry?" → No ✗ → DENIED
// =============================================================================
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using RolesAssignments.Authorization.Resources;
using RolesAssignments.Data;
using RolesAssignments.Extensions;
using RolesAssignments.Models;
namespace RolesAssignments.Authorization.Handlers;
public class ProgressEntryAuthorizationHandler
: AuthorizationHandler<OperationAuthorizationRequirement, ProgressEntryResource>
// ^^^^^^^^^^^^^^^^^^^^^
// Notice the second type parameter is ProgressEntryResource, not StudentResource.
// This tells the framework: "Only call me when the resource is a ProgressEntryResource."
// The StudentAuthorizationHandler will NOT fire for ProgressEntryResource, and this
// handler will NOT fire for StudentResource. The framework routes automatically by type.
{
// We need the assignment repository to check what type of assignment the
// user has (PrimaryTeacher vs Paraeducator), because the rules differ.
private readonly IAssignmentRepository _assignments;
public ProgressEntryAuthorizationHandler(IAssignmentRepository assignments)
{
_assignments = assignments;
}
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
OperationAuthorizationRequirement requirement,
ProgressEntryResource resource)
{
var userId = context.User.GetUserId();
// Look up how this user is connected to the student this entry belongs to.
var assignment = await _assignments.GetActiveAssignment(userId, resource.StudentId);
// No assignment at all? No access to anything.
if (assignment is null)
return;
switch (requirement.Name)
{
case nameof(Operations.EditProgressEntry):
case nameof(Operations.DeleteProgressEntry):
// Teachers can edit/delete ANY entry for their assigned students.
// The student-level check (Layer 1) already confirmed they're assigned,
// so here we just need to confirm their assignment type grants write access.
if (assignment.AssignmentType is AssignmentType.PrimaryTeacher
or AssignmentType.TemporaryCoverage)
{
context.Succeed(requirement);
}
// Paraeducators can only touch entries THEY created.
// We compare the entry's CreatedByUserId against the current user's ID.
else if (assignment.AssignmentType == AssignmentType.Paraeducator
&& resource.CreatedByUserId == userId)
{
context.Succeed(requirement);
}
// Supervisors (and anyone else) fall through without Succeed() → DENIED.
break;
}
}
}
@@ -0,0 +1,142 @@
// =============================================================================
// StudentAuthorizationHandler.cs
// =============================================================================
// THIS IS THE MOST IMPORTANT FILE IN THE AUTHORIZATION SYSTEM.
//
// This handler answers the question: "Can this user perform [operation] on
// this student?" It's called automatically by ASP.NET Core whenever you call:
//
// await _auth.AuthorizeAsync(User, new StudentResource(studentId), Operations.SomeOp);
//
// HOW IT GETS CALLED (the chain):
// --------------------------------
// 1. Your controller calls AuthorizeAsync(user, resource, operation).
// 2. The framework looks through all registered IAuthorizationHandler classes.
// 3. This class inherits from AuthorizationHandler<OperationAuthorizationRequirement, StudentResource>.
// The two generic type parameters tell the framework: "I handle checks where
// the requirement is an OperationAuthorizationRequirement AND the resource
// is a StudentResource."
// 4. The framework's base class checks: does the requirement type match? Does
// the resource type match? If yes to both, it calls HandleRequirementAsync.
// 5. If you call context.Succeed(requirement), the check passes. If you don't
// call Succeed, the check fails (implicit deny — secure by default).
//
// WHAT IT DECIDES:
// ----------------
// - Supervisors: can view students and generate reports. Cannot write anything.
// - Anyone with an active assignment: can view the student.
// - PrimaryTeacher (or TemporaryCoverage): can create/edit goals and view notes.
// - Teacher & Paraeducator: can add progress entries.
// - No assignment at all: denied everything (the method returns without calling Succeed).
// =============================================================================
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using RolesAssignments.Authorization.Resources;
using RolesAssignments.Data;
using RolesAssignments.Extensions;
using RolesAssignments.Models;
namespace RolesAssignments.Authorization.Handlers;
public class StudentAuthorizationHandler
: AuthorizationHandler<OperationAuthorizationRequirement, StudentResource>
{
// We inject the assignment repository so we can look up whether the user
// is assigned to the student. In this prototype, the repository returns
// hardcoded data. In production, it would query MySQL via Dapper.
private readonly IAssignmentRepository _assignments;
public StudentAuthorizationHandler(IAssignmentRepository assignments)
{
_assignments = assignments;
}
// This is the method the framework calls. We NEVER call it directly ourselves.
//
// Parameters (all provided by the framework):
// context - Carries the ClaimsPrincipal (the logged-in user). Also the
// object you call .Succeed() on to approve the request.
// requirement - The operation being attempted (e.g., Operations.EditGoal).
// Its .Name property is the string we switch on.
// resource - The StudentResource we passed in from the controller.
// Contains the StudentId we're checking access for.
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
OperationAuthorizationRequirement requirement,
StudentResource resource)
{
// Extract the current user's ID from their claims.
// (See ClaimsPrincipalExtensions.cs for how this extension method works.)
var userId = context.User.GetUserId();
// Look up the user's assignment to this specific student.
// If this returns null, the user has NO connection to this student at all.
var assignment = await _assignments.GetActiveAssignment(userId, resource.StudentId);
// NO ASSIGNMENT = NO ACCESS
// If the user isn't assigned to this student, we simply return without
// calling Succeed(). This is an implicit denial — the framework treats
// "nobody said yes" as "access denied."
if (assignment is null)
return;
// The user IS assigned. Now decide based on what they're trying to do
// and what type of assignment they have.
switch (requirement.Name)
{
// --- VIEWING ---
// Any assigned user can view the student, regardless of assignment type.
// This covers teachers, paraeducators, supervisors, and temp coverage.
case nameof(Operations.ViewStudent):
context.Succeed(requirement);
break;
// --- REPORTS ---
// Reports are read-only, so any assigned user can generate them.
case nameof(Operations.GenerateReport):
context.Succeed(requirement);
break;
// --- CREATING AND EDITING GOALS ---
// Only the PrimaryTeacher (or someone with TemporaryCoverage) can
// create or modify goals. Paraeducators can see goals but not change
// them. Supervisors can view but not write.
case nameof(Operations.CreateGoal):
case nameof(Operations.EditGoal):
if (assignment.AssignmentType is AssignmentType.PrimaryTeacher
or AssignmentType.TemporaryCoverage)
{
context.Succeed(requirement);
}
break;
// --- ADDING PROGRESS ENTRIES ---
// Both teachers and paraeducators can add progress entries.
// (The edit/delete rules are more restrictive — see
// ProgressEntryAuthorizationHandler.cs for those.)
// Supervisors cannot add entries.
case nameof(Operations.AddProgressEntry):
if (assignment.AssignmentType is AssignmentType.PrimaryTeacher
or AssignmentType.TemporaryCoverage
or AssignmentType.Paraeducator)
{
context.Succeed(requirement);
}
break;
// --- VIEWING SENSITIVE NOTES ---
// Only the primary teacher can see confidential notes about a student.
case nameof(Operations.ViewSensitiveNotes):
if (assignment.AssignmentType == AssignmentType.PrimaryTeacher)
{
context.Succeed(requirement);
}
break;
// If the operation doesn't match any of the above cases, we fall
// through without calling Succeed() — which means ACCESS DENIED.
// This is "deny by default" and is the safest approach.
}
}
}
@@ -0,0 +1,70 @@
// =============================================================================
// Operations.cs
// =============================================================================
// This class defines every OPERATION (action) that exists in the system.
// Think of it as a menu of "things a user might try to do."
//
// Each field is an OperationAuthorizationRequirement — a built-in ASP.NET Core
// class that just wraps a string name. The authorization handlers switch on
// this name to decide if the user is allowed to perform the action.
//
// WHY nameof()?
// -------------
// You'll see `nameof(ViewStudent)` everywhere. This is a C# feature that takes
// the NAME of the thing you point it at and turns it into a string.
//
// So `nameof(ViewStudent)` produces the string "ViewStudent".
//
// It's the same as writing `Name = "ViewStudent"`, but safer — if you rename
// the field later, nameof() updates automatically. With a hardcoded string,
// you'd have a silent bug where the name no longer matches.
//
// These are NOT connected to controller methods or endpoint names.
// They're a self-contained vocabulary of actions that the handlers interpret.
// =============================================================================
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace RolesAssignments.Authorization;
public static class Operations
{
// --- Student-level operations ---
// Can the user SEE this student's information at all?
public static readonly OperationAuthorizationRequirement ViewStudent =
new() { Name = nameof(ViewStudent) };
// Can the user create a new goal for this student?
public static readonly OperationAuthorizationRequirement CreateGoal =
new() { Name = nameof(CreateGoal) };
// Can the user edit an existing goal for this student?
public static readonly OperationAuthorizationRequirement EditGoal =
new() { Name = nameof(EditGoal) };
// Can the user add a new progress entry for this student?
public static readonly OperationAuthorizationRequirement AddProgressEntry =
new() { Name = nameof(AddProgressEntry) };
// --- Entry-level operations (checked AFTER the student-level check) ---
// Can the user edit this specific progress entry?
// (For teachers: yes, any entry. For paraeducators: only their own.)
public static readonly OperationAuthorizationRequirement EditProgressEntry =
new() { Name = nameof(EditProgressEntry) };
// Can the user delete this specific progress entry?
public static readonly OperationAuthorizationRequirement DeleteProgressEntry =
new() { Name = nameof(DeleteProgressEntry) };
// --- Other operations ---
// Can the user view sensitive/confidential notes about this student?
public static readonly OperationAuthorizationRequirement ViewSensitiveNotes =
new() { Name = nameof(ViewSensitiveNotes) };
// Can the user generate reports for this student?
public static readonly OperationAuthorizationRequirement GenerateReport =
new() { Name = nameof(GenerateReport) };
}
@@ -0,0 +1,21 @@
// =============================================================================
// ProgressEntryResource.cs
// =============================================================================
// This resource record carries enough information for the authorization handler
// to decide if the current user can edit or delete a SPECIFIC progress entry.
//
// It includes:
// - StudentId: needed to verify the user has any access to this student at all
// - EntryId: identifies which entry we're talking about
// - CreatedByUserId: WHO originally wrote this entry — this is the key field
// for the "you can only edit your own entries" rule.
//
// The reason we pass CreatedByUserId here (instead of making the handler look
// it up) is that the controller already loaded the entry to check if it exists.
// Since we already have the data, we pass it along to avoid a redundant
// database call inside the handler.
// =============================================================================
namespace RolesAssignments.Authorization.Resources;
public record ProgressEntryResource(int StudentId, int EntryId, int CreatedByUserId);
@@ -0,0 +1,22 @@
// =============================================================================
// StudentResource.cs
// =============================================================================
// This is a "resource record" — a tiny data carrier that gets passed to the
// authorization system so it knows WHICH student we're asking about.
//
// When you call:
// await _auth.AuthorizeAsync(User, new StudentResource(studentId), Operations.EditGoal);
//
// ...the framework takes this object and hands it to every authorization
// handler that declared it handles StudentResource. The handler then uses
// the StudentId to look up the user's assignment and make a decision.
//
// A "record" in C# is a special class that's designed to just hold data.
// Writing `public record StudentResource(int StudentId);` is shorthand for
// a class with a constructor, a read-only property, equality checks, etc.
// It's the modern C# way of saying "this is just a bag of values."
// =============================================================================
namespace RolesAssignments.Authorization.Resources;
public record StudentResource(int StudentId);
@@ -0,0 +1,127 @@
// =============================================================================
// GoalsController.cs
// =============================================================================
// Full CRUD for goals, nested under /api/students/{studentId}/goals.
//
// Every action follows the same pattern:
// 1. AUTHORIZE — ask the authorization system "can this user do this?"
// 2. LOAD — fetch the resource from the data layer
// 3. ACT — perform the business logic
//
// Notice that the controller has ZERO role-checking logic. There's no
// `if (user.Role == "Teacher")` anywhere. All of that lives in the
// StudentAuthorizationHandler. If the rules change (e.g., paraeducators
// should also be able to create goals), you update the handler — not the
// controller.
// =============================================================================
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RolesAssignments.Authorization;
using RolesAssignments.Authorization.Resources;
using RolesAssignments.Data;
using RolesAssignments.Extensions;
using RolesAssignments.Models;
namespace RolesAssignments.Controllers;
[ApiController]
[Route("api/students/{studentId}/goals")]
[Authorize]
public class GoalsController : ControllerBase
{
private readonly IAuthorizationService _auth;
private readonly IGoalRepository _goals;
public GoalsController(IAuthorizationService auth, IGoalRepository goals)
{
_auth = auth;
_goals = goals;
}
/// <summary>
/// GET /api/students/{studentId}/goals
/// Lists all goals for a student. Requires ViewStudent permission.
///
/// Any assigned user (teacher, para, supervisor) can view goals.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetGoalsForStudent(int studentId)
{
// Step 1: AUTHORIZE — can this user view this student?
var authResult = await _auth.AuthorizeAsync(
User, new StudentResource(studentId), Operations.ViewStudent);
if (!authResult.Succeeded) return Forbid();
// Step 2 & 3: LOAD and return
var goals = await _goals.GetByStudentId(studentId);
return Ok(goals);
}
/// <summary>
/// GET /api/students/{studentId}/goals/{goalId}
/// Returns a single goal. Requires ViewStudent permission.
/// </summary>
[HttpGet("{goalId}")]
public async Task<IActionResult> GetGoal(int studentId, int goalId)
{
var authResult = await _auth.AuthorizeAsync(
User, new StudentResource(studentId), Operations.ViewStudent);
if (!authResult.Succeeded) return Forbid();
var goal = await _goals.GetById(goalId);
// Double-check that the goal actually belongs to this student.
// This prevents a URL manipulation attack where someone guesses a
// goal ID that belongs to a different student.
if (goal is null || goal.StudentId != studentId) return NotFound();
return Ok(goal);
}
/// <summary>
/// POST /api/students/{studentId}/goals
/// Creates a new goal. Requires CreateGoal permission.
///
/// Only PrimaryTeacher and TemporaryCoverage assignments grant this.
/// Paraeducators and Supervisors will get 403 Forbidden.
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateGoal(int studentId, [FromBody] CreateGoalRequest request)
{
// Note: We check CreateGoal here, not ViewStudent. The handler grants
// CreateGoal only to PrimaryTeacher and TemporaryCoverage.
var authResult = await _auth.AuthorizeAsync(
User, new StudentResource(studentId), Operations.CreateGoal);
if (!authResult.Succeeded) return Forbid();
var goalId = await _goals.Create(studentId, request, User.GetUserId());
// Return 201 Created with a Location header pointing to the new resource.
// CreatedAtAction generates the URL automatically based on the named action.
return CreatedAtAction(nameof(GetGoal), new { studentId, goalId }, null);
}
/// <summary>
/// PUT /api/students/{studentId}/goals/{goalId}
/// Updates an existing goal. Requires EditGoal permission.
///
/// Only PrimaryTeacher and TemporaryCoverage assignments grant this.
/// </summary>
[HttpPut("{goalId}")]
public async Task<IActionResult> UpdateGoal(
int studentId, int goalId, [FromBody] UpdateGoalRequest request)
{
var authResult = await _auth.AuthorizeAsync(
User, new StudentResource(studentId), Operations.EditGoal);
if (!authResult.Succeeded) return Forbid();
var goal = await _goals.GetById(goalId);
if (goal is null || goal.StudentId != studentId) return NotFound();
await _goals.Update(goalId, request, User.GetUserId());
// 204 No Content — the update succeeded but there's nothing to return.
return NoContent();
}
}
@@ -0,0 +1,177 @@
// =============================================================================
// ProgressEntriesController.cs
// =============================================================================
// This controller demonstrates the TWO-LAYER authorization pattern.
//
// For VIEWING and CREATING entries, we only need Layer 1 (student-level):
// "Is this user assigned to the student?"
//
// For EDITING and DELETING entries, we need BOTH layers:
// Layer 1 (student-level): "Is this user assigned to the student?"
// Layer 2 (entry-level): "Can this user edit THIS specific entry?"
//
// The entry-level check is needed because paraeducators can only edit/delete
// entries they created themselves. Teachers can edit/delete any entry.
//
// THE FLOW for edit/delete:
// 1. Check student-level access (StudentResource + ViewStudent)
// 2. Load the entry from the database
// 3. Check entry-level ownership (ProgressEntryResource + EditProgressEntry)
// 4. If both pass, proceed with the operation
//
// If either check fails, the user gets 403 Forbidden.
// =============================================================================
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RolesAssignments.Authorization;
using RolesAssignments.Authorization.Resources;
using RolesAssignments.Data;
using RolesAssignments.Extensions;
using RolesAssignments.Models;
namespace RolesAssignments.Controllers;
[ApiController]
[Route("api/students/{studentId}/entries")]
[Authorize]
public class ProgressEntriesController : ControllerBase
{
private readonly IAuthorizationService _auth;
private readonly IProgressEntryRepository _entries;
public ProgressEntriesController(IAuthorizationService auth, IProgressEntryRepository entries)
{
_auth = auth;
_entries = entries;
}
/// <summary>
/// GET /api/students/{studentId}/entries
/// Lists all progress entries for a student. Requires ViewStudent permission.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetEntriesForStudent(int studentId)
{
// Just a student-level check — anyone assigned can view entries.
var authResult = await _auth.AuthorizeAsync(
User, new StudentResource(studentId), Operations.ViewStudent);
if (!authResult.Succeeded) return Forbid();
var entries = await _entries.GetByStudentId(studentId);
return Ok(entries);
}
/// <summary>
/// GET /api/students/{studentId}/entries/{entryId}
/// Returns a single progress entry. Requires ViewStudent permission.
/// </summary>
[HttpGet("{entryId}")]
public async Task<IActionResult> GetEntry(int studentId, int entryId)
{
var authResult = await _auth.AuthorizeAsync(
User, new StudentResource(studentId), Operations.ViewStudent);
if (!authResult.Succeeded) return Forbid();
var entry = await _entries.GetById(entryId);
if (entry is null || entry.StudentId != studentId) return NotFound();
return Ok(entry);
}
/// <summary>
/// POST /api/students/{studentId}/entries
/// Creates a new progress entry. Requires AddProgressEntry permission.
///
/// Teachers and Paraeducators can both add entries.
/// Supervisors cannot.
/// </summary>
[HttpPost]
public async Task<IActionResult> AddEntry(int studentId, [FromBody] CreateEntryRequest request)
{
var authResult = await _auth.AuthorizeAsync(
User, new StudentResource(studentId), Operations.AddProgressEntry);
if (!authResult.Succeeded) return Forbid();
var entryId = await _entries.Create(studentId, request, User.GetUserId());
return CreatedAtAction(nameof(GetEntry), new { studentId, entryId }, null);
}
/// <summary>
/// PUT /api/students/{studentId}/entries/{entryId}
/// Updates an existing progress entry.
///
/// THIS IS WHERE THE TWO-LAYER CHECK HAPPENS:
///
/// Layer 1: Can the user access this student at all?
/// → Uses StudentResource + ViewStudent
/// → Checked by StudentAuthorizationHandler
///
/// Layer 2: Can the user edit THIS SPECIFIC entry?
/// → Uses ProgressEntryResource + EditProgressEntry
/// → Checked by ProgressEntryAuthorizationHandler
/// → Teachers: can edit any entry
/// → Paraeducators: can only edit entries they created
/// → Supervisors: cannot edit anything
/// </summary>
[HttpPut("{entryId}")]
public async Task<IActionResult> UpdateEntry(
int studentId, int entryId, [FromBody] UpdateEntryRequest request)
{
// LAYER 1: Student-level access check
var studentAuth = await _auth.AuthorizeAsync(
User, new StudentResource(studentId), Operations.ViewStudent);
if (!studentAuth.Succeeded) return Forbid();
// Load the entry — we need it to check ownership in Layer 2
var entry = await _entries.GetById(entryId);
if (entry is null || entry.StudentId != studentId) return NotFound();
// LAYER 2: Entry-level ownership check
// Notice we're using a DIFFERENT resource type (ProgressEntryResource)
// and a DIFFERENT operation (EditProgressEntry).
// This causes the framework to call ProgressEntryAuthorizationHandler
// instead of StudentAuthorizationHandler.
var entryAuth = await _auth.AuthorizeAsync(
User,
new ProgressEntryResource(entry.StudentId, entry.Id, entry.CreatedByUserId),
Operations.EditProgressEntry);
if (!entryAuth.Succeeded) return Forbid();
// Both layers passed — proceed with the update
await _entries.Update(entryId, request, User.GetUserId());
return NoContent();
}
/// <summary>
/// DELETE /api/students/{studentId}/entries/{entryId}
/// Soft-deletes a progress entry. Same two-layer check as UpdateEntry.
///
/// Teachers: can delete any entry for their assigned students.
/// Paraeducators: can only delete entries they created.
/// Supervisors: cannot delete.
/// </summary>
[HttpDelete("{entryId}")]
public async Task<IActionResult> DeleteEntry(int studentId, int entryId)
{
// Layer 1: Student access
var studentAuth = await _auth.AuthorizeAsync(
User, new StudentResource(studentId), Operations.ViewStudent);
if (!studentAuth.Succeeded) return Forbid();
// Load the entry
var entry = await _entries.GetById(entryId);
if (entry is null || entry.StudentId != studentId) return NotFound();
// Layer 2: Entry ownership
var entryAuth = await _auth.AuthorizeAsync(
User,
new ProgressEntryResource(entry.StudentId, entry.Id, entry.CreatedByUserId),
Operations.DeleteProgressEntry);
if (!entryAuth.Succeeded) return Forbid();
// Soft-delete (marks as deleted, doesn't remove the row)
await _entries.SoftDelete(entryId, User.GetUserId());
return Ok();
}
}
@@ -0,0 +1,108 @@
// =============================================================================
// StudentsController.cs
// =============================================================================
// This controller demonstrates TWO different authorization patterns:
//
// 1. LIST endpoint (GET /api/students):
// Authorization is handled IN THE QUERY — the repository only returns
// students the user is assigned to. No AuthorizeAsync call needed because
// unauthorized records never leave the database.
//
// 2. SINGLE-RESOURCE endpoint (GET /api/students/{id}):
// Authorization is checked EXPLICITLY by calling AuthorizeAsync with
// a StudentResource. The authorization handler decides yes/no.
//
// WHY THE DIFFERENCE?
// -------------------
// For a single resource, you can load it and then ask "can this user see this?"
// For a list, you can't load ALL records and filter — that's slow and leaks
// information (the user could infer how many total records exist). So the
// query itself is scoped to only return what the user is allowed to see.
// =============================================================================
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RolesAssignments.Authorization;
using RolesAssignments.Authorization.Resources;
using RolesAssignments.Data;
using RolesAssignments.Extensions;
namespace RolesAssignments.Controllers;
[ApiController]
[Route("api/students")]
[Authorize] // This attribute means: "the user must be authenticated (logged in)
// to access ANY endpoint in this controller." It doesn't check roles
// or permissions — just that the user is someone, not anonymous.
public class StudentsController : ControllerBase
{
private readonly IAuthorizationService _auth;
private readonly IStudentRepository _students;
// These dependencies are injected by the DI container (configured in Program.cs).
// The controller doesn't know or care whether it's getting dummy data or real data.
public StudentsController(IAuthorizationService auth, IStudentRepository students)
{
_auth = auth;
_students = students;
}
/// <summary>
/// GET /api/students
/// Returns the list of students that the current user is allowed to see.
///
/// This is the "scoped list" pattern:
/// - Ms. Rivera (User 1) → sees Students 101, 102 (her assigned students)
/// - Mr. Daniels (User 2) → sees Student 101 only (his assignment)
/// - Dr. Patel (User 3) → sees all students (supervisor assignments)
/// </summary>
[HttpGet]
public async Task<IActionResult> GetMyStudents()
{
// Get the current user's ID from their claims.
var userId = User.GetUserId();
// The repository query is already scoped — it joins through the
// assignments table, so it only returns students this user has access to.
// No AuthorizeAsync call needed here.
var students = await _students.GetAccessibleStudents(userId);
return Ok(students);
}
/// <summary>
/// GET /api/students/{studentId}
/// Returns a single student. Requires the user to be assigned to this student.
///
/// This is the "authorize-then-load" pattern:
/// 1. Check: can this user view this student?
/// 2. If yes, load and return the data.
/// 3. If no, return 403 Forbidden.
/// </summary>
[HttpGet("{studentId}")]
public async Task<IActionResult> GetStudent(int studentId)
{
// AUTHORIZATION CHECK
// We create a StudentResource (just a carrier with the student ID)
// and pass it to AuthorizeAsync along with the operation we want to
// perform (ViewStudent). The framework then calls our
// StudentAuthorizationHandler, which checks the user's assignment.
var authResult = await _auth.AuthorizeAsync(
User, // The current user (ClaimsPrincipal)
new StudentResource(studentId), // The resource we're asking about
Operations.ViewStudent); // The operation we want to perform
// If the handler didn't call context.Succeed(), the result is "not succeeded"
// and we return 403 Forbidden. The user is authenticated (we know who they
// are) but not authorized (they're not allowed to do this).
if (!authResult.Succeeded)
return Forbid();
// Authorization passed — now load the actual data.
var student = await _students.GetById(studentId);
if (student is null)
return NotFound();
return Ok(student);
}
}
@@ -0,0 +1,127 @@
// =============================================================================
// DummyAssignmentRepository.cs
// =============================================================================
// This is the HARDCODED version of the assignment repository.
// It simulates what the database would return, without needing a real database.
//
// THE DEMO DATA:
// ┌─────────┬───────────────┬────────────┬───────────────────┐
// │ User ID │ Name │ Student ID │ Assignment Type │
// ├─────────┼───────────────┼────────────┼───────────────────┤
// │ 1 │ Ms. Rivera │ 101 │ PrimaryTeacher │
// │ 1 │ Ms. Rivera │ 102 │ PrimaryTeacher │
// │ 2 │ Mr. Daniels │ 101 │ Paraeducator │
// │ 3 │ Dr. Patel │ 101 │ Supervisor │
// │ 3 │ Dr. Patel │ 102 │ Supervisor │
// │ 3 │ Dr. Patel │ 103 │ Supervisor │
// │ 3 │ Dr. Patel │ 104 │ Supervisor │
// └─────────┴───────────────┴────────────┴───────────────────┘
//
// Notice: Student 103 and 104 are NOT assigned to User 1 (Ms. Rivera) or
// User 2 (Mr. Daniels). If those users try to access those students, the
// authorization handler will get null from GetActiveAssignment and deny access.
//
// Dr. Patel (Supervisor) has Supervisor-type assignments to ALL students,
// which grants read-only access everywhere.
// =============================================================================
using RolesAssignments.Models;
namespace RolesAssignments.Data;
public class DummyAssignmentRepository : IAssignmentRepository
{
// This list simulates the student_assignments table in the database.
// Each entry represents one row — one relationship between a user and a student.
private static readonly List<StudentAssignment> _assignments = new()
{
// Ms. Rivera (User 1) is the primary teacher for Students 101 and 102
new StudentAssignment
{
Id = 1, UserId = 1, StudentId = 101,
AssignmentType = AssignmentType.PrimaryTeacher,
StartDate = new DateTime(2025, 9, 1), EndDate = null, IsActive = true
},
new StudentAssignment
{
Id = 2, UserId = 1, StudentId = 102,
AssignmentType = AssignmentType.PrimaryTeacher,
StartDate = new DateTime(2025, 9, 1), EndDate = null, IsActive = true
},
// Mr. Daniels (User 2) is a paraeducator assigned to Student 101
new StudentAssignment
{
Id = 3, UserId = 2, StudentId = 101,
AssignmentType = AssignmentType.Paraeducator,
StartDate = new DateTime(2025, 9, 1), EndDate = null, IsActive = true
},
// Dr. Patel (User 3) is a supervisor for ALL students
new StudentAssignment
{
Id = 4, UserId = 3, StudentId = 101,
AssignmentType = AssignmentType.Supervisor,
StartDate = new DateTime(2025, 9, 1), EndDate = null, IsActive = true
},
new StudentAssignment
{
Id = 5, UserId = 3, StudentId = 102,
AssignmentType = AssignmentType.Supervisor,
StartDate = new DateTime(2025, 9, 1), EndDate = null, IsActive = true
},
new StudentAssignment
{
Id = 6, UserId = 3, StudentId = 103,
AssignmentType = AssignmentType.Supervisor,
StartDate = new DateTime(2025, 9, 1), EndDate = null, IsActive = true
},
new StudentAssignment
{
Id = 7, UserId = 3, StudentId = 104,
AssignmentType = AssignmentType.Supervisor,
StartDate = new DateTime(2025, 9, 1), EndDate = null, IsActive = true
},
};
/// <summary>
/// Finds an active assignment between the given user and student.
/// Returns null if no active assignment exists.
/// </summary>
public Task<StudentAssignment?> GetActiveAssignment(int userId, int studentId)
{
// This LINQ query does in-memory what the SQL query would do in a real database.
var now = DateTime.UtcNow;
var assignment = _assignments.FirstOrDefault(a =>
a.UserId == userId
&& a.StudentId == studentId
&& a.IsActive
&& a.StartDate <= now
&& (a.EndDate == null || a.EndDate >= now));
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// In production, this method would use Dapper to query MySQL:
//
// public async Task<StudentAssignment?> GetActiveAssignment(int userId, int studentId)
// {
// return await _db.QueryFirstOrDefaultAsync<StudentAssignment>(
// @"SELECT id, user_id AS UserId, student_id AS StudentId,
// assignment_type AS AssignmentType,
// start_date AS StartDate, end_date AS EndDate,
// is_active AS IsActive
// FROM student_assignments
// WHERE user_id = @UserId
// AND student_id = @StudentId
// AND is_active = TRUE
// AND start_date <= CURDATE()
// AND (end_date IS NULL OR end_date >= CURDATE())
// LIMIT 1",
// new { UserId = userId, StudentId = studentId });
// }
return Task.FromResult(assignment);
}
}
@@ -0,0 +1,121 @@
// =============================================================================
// DummyGoalRepository.cs
// =============================================================================
// Hardcoded goal data. Goals belong to students and can only be created/edited
// by the PrimaryTeacher (enforced by the authorization handler, not here).
//
// This repository is intentionally "dumb" — it just stores and retrieves data.
// Authorization is handled elsewhere (in the handlers and controllers).
// This separation of concerns is important: the repository doesn't know or
// care about permissions. It just does CRUD.
// =============================================================================
using RolesAssignments.Models;
namespace RolesAssignments.Data;
public class DummyGoalRepository : IGoalRepository
{
// Simulates the goals table. We use a static list so data persists
// across requests during a single run of the app (but resets on restart).
private static readonly List<Goal> _goals = new()
{
new Goal
{
Id = 1, StudentId = 101,
Title = "Reading Fluency",
Description = "Student will read 20 words per minute by end of semester.",
CreatedByUserId = 1, CreatedAt = new DateTime(2025, 9, 15)
},
new Goal
{
Id = 2, StudentId = 101,
Title = "Math Facts",
Description = "Student will master addition facts to 10.",
CreatedByUserId = 1, CreatedAt = new DateTime(2025, 9, 15)
},
new Goal
{
Id = 3, StudentId = 102,
Title = "Social Skills",
Description = "Student will initiate peer interactions 3 times per day.",
CreatedByUserId = 1, CreatedAt = new DateTime(2025, 9, 16)
},
};
// Counter for generating new IDs (simulates AUTO_INCREMENT)
private static int _nextId = 4;
public Task<IEnumerable<Goal>> GetByStudentId(int studentId)
{
var goals = _goals.Where(g => g.StudentId == studentId);
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// return await _db.QueryAsync<Goal>(
// @"SELECT id AS Id, student_id AS StudentId, title AS Title,
// description AS Description,
// created_by_user_id AS CreatedByUserId,
// created_at AS CreatedAt
// FROM goals
// WHERE student_id = @StudentId
// ORDER BY created_at",
// new { StudentId = studentId });
return Task.FromResult(goals);
}
public Task<Goal?> GetById(int goalId)
{
var goal = _goals.FirstOrDefault(g => g.Id == goalId);
return Task.FromResult(goal);
}
public Task<int> Create(int studentId, CreateGoalRequest request, int createdByUserId)
{
var goal = new Goal
{
Id = _nextId++,
StudentId = studentId,
Title = request.Title,
Description = request.Description,
CreatedByUserId = createdByUserId,
CreatedAt = DateTime.UtcNow
};
_goals.Add(goal);
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// var goalId = await _db.ExecuteScalarAsync<int>(
// @"INSERT INTO goals (student_id, title, description, created_by_user_id, created_at)
// VALUES (@StudentId, @Title, @Description, @CreatedByUserId, NOW());
// SELECT LAST_INSERT_ID();",
// new { StudentId = studentId, request.Title, request.Description, CreatedByUserId = createdByUserId });
return Task.FromResult(goal.Id);
}
public Task Update(int goalId, UpdateGoalRequest request, int updatedByUserId)
{
var goal = _goals.FirstOrDefault(g => g.Id == goalId);
if (goal is not null)
{
goal.Title = request.Title;
goal.Description = request.Description;
}
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// await _db.ExecuteAsync(
// @"UPDATE goals
// SET title = @Title, description = @Description,
// updated_by_user_id = @UpdatedByUserId, updated_at = NOW()
// WHERE id = @GoalId",
// new { GoalId = goalId, request.Title, request.Description, UpdatedByUserId = updatedByUserId });
return Task.CompletedTask;
}
}
@@ -0,0 +1,151 @@
// =============================================================================
// DummyProgressEntryRepository.cs
// =============================================================================
// Hardcoded progress entry data. This is the entity that demonstrates the
// ownership model — entries have a CreatedByUserId, and the authorization
// handler uses it to determine who can edit/delete them.
//
// The seed data includes entries created by different users so you can test:
// - Ms. Rivera (User 1) editing any entry for her students → ALLOWED
// - Mr. Daniels (User 2) editing his own entry → ALLOWED
// - Mr. Daniels (User 2) editing Ms. Rivera's entry → DENIED
// =============================================================================
using RolesAssignments.Models;
namespace RolesAssignments.Data;
public class DummyProgressEntryRepository : IProgressEntryRepository
{
// Simulates the progress_entries table
private static readonly List<ProgressEntry> _entries = new()
{
// Entry created by Ms. Rivera (User 1) for Student 101
new ProgressEntry
{
Id = 1, StudentId = 101,
Notes = "Student read 12 words per minute today. Showing improvement.",
CreatedByUserId = 1, // Ms. Rivera made this entry
CreatedAt = new DateTime(2025, 10, 1),
IsDeleted = false
},
// Entry created by Mr. Daniels (User 2) for Student 101
new ProgressEntry
{
Id = 2, StudentId = 101,
Notes = "Worked on addition facts during small group. Got 7/10 correct.",
CreatedByUserId = 2, // Mr. Daniels made this entry
CreatedAt = new DateTime(2025, 10, 2),
IsDeleted = false
},
// Another entry by Ms. Rivera for Student 102
new ProgressEntry
{
Id = 3, StudentId = 102,
Notes = "Student initiated conversation with peer during recess.",
CreatedByUserId = 1, // Ms. Rivera made this entry
CreatedAt = new DateTime(2025, 10, 3),
IsDeleted = false
},
};
private static int _nextId = 4;
public Task<IEnumerable<ProgressEntry>> GetByStudentId(int studentId)
{
var entries = _entries.Where(e => e.StudentId == studentId && !e.IsDeleted);
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// return await _db.QueryAsync<ProgressEntry>(
// @"SELECT id AS Id, student_id AS StudentId, notes AS Notes,
// created_by_user_id AS CreatedByUserId,
// created_at AS CreatedAt, is_deleted AS IsDeleted
// FROM progress_entries
// WHERE student_id = @StudentId AND is_deleted = FALSE
// ORDER BY created_at DESC",
// new { StudentId = studentId });
return Task.FromResult(entries);
}
public Task<ProgressEntry?> GetById(int entryId)
{
var entry = _entries.FirstOrDefault(e => e.Id == entryId && !e.IsDeleted);
return Task.FromResult(entry);
}
public Task<int> Create(int studentId, CreateEntryRequest request, int createdByUserId)
{
var entry = new ProgressEntry
{
Id = _nextId++,
StudentId = studentId,
Notes = request.Notes,
CreatedByUserId = createdByUserId,
CreatedAt = DateTime.UtcNow,
IsDeleted = false
};
_entries.Add(entry);
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// var entryId = await _db.ExecuteScalarAsync<int>(
// @"INSERT INTO progress_entries (student_id, notes, created_by_user_id, created_at, is_deleted)
// VALUES (@StudentId, @Notes, @CreatedByUserId, NOW(), FALSE);
// SELECT LAST_INSERT_ID();",
// new { StudentId = studentId, request.Notes, CreatedByUserId = createdByUserId });
return Task.FromResult(entry.Id);
}
public Task Update(int entryId, UpdateEntryRequest request, int updatedByUserId)
{
var entry = _entries.FirstOrDefault(e => e.Id == entryId && !e.IsDeleted);
if (entry is not null)
{
entry.Notes = request.Notes;
}
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// await _db.ExecuteAsync(
// @"UPDATE progress_entries
// SET notes = @Notes, updated_by_user_id = @UpdatedByUserId, updated_at = NOW()
// WHERE id = @EntryId AND is_deleted = FALSE",
// new { EntryId = entryId, request.Notes, UpdatedByUserId = updatedByUserId });
return Task.CompletedTask;
}
/// <summary>
/// Soft-delete: marks the entry as deleted instead of removing it.
/// The row stays in the database for auditing purposes.
/// </summary>
public Task SoftDelete(int entryId, int deletedByUserId)
{
var entry = _entries.FirstOrDefault(e => e.Id == entryId);
if (entry is not null)
{
entry.IsDeleted = true;
}
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// await _db.ExecuteAsync(
// @"UPDATE progress_entries
// SET is_deleted = TRUE,
// deleted_by_user_id = @DeletedByUserId,
// deleted_at = NOW()
// WHERE id = @EntryId",
// new { EntryId = entryId, DeletedByUserId = deletedByUserId });
return Task.CompletedTask;
}
}
@@ -0,0 +1,112 @@
// =============================================================================
// DummyStudentRepository.cs
// =============================================================================
// Hardcoded student data. The most interesting method here is
// GetAccessibleStudents, which demonstrates the "scoped list" pattern.
//
// THE IDEA:
// For single-resource endpoints (GET /students/101), the controller calls
// AuthorizeAsync to check access. But for LIST endpoints (GET /students),
// you can't load ALL students and then authorize each one — that's slow
// and leaks the existence of records the user shouldn't see.
//
// Instead, the list query itself is scoped through the assignments table.
// The query joins students through student_assignments, so it only returns
// students the user is assigned to. Unauthorized records never leave the
// database.
// =============================================================================
using RolesAssignments.Models;
namespace RolesAssignments.Data;
public class DummyStudentRepository : IStudentRepository
{
// Simulates the students table
private static readonly List<Student> _students = new()
{
new Student { Id = 101, Identifier = "Student A", ProgramYear = "2025-2026", Age = 8, IsDeleted = false },
new Student { Id = 102, Identifier = "Student B", ProgramYear = "2025-2026", Age = 9, IsDeleted = false },
new Student { Id = 103, Identifier = "Student C", ProgramYear = "2025-2026", Age = 7, IsDeleted = false },
new Student { Id = 104, Identifier = "Student D", ProgramYear = "2025-2026", Age = 10, IsDeleted = false },
};
// We need the assignment data to scope the list query
private readonly IAssignmentRepository _assignments;
public DummyStudentRepository(IAssignmentRepository assignments)
{
_assignments = assignments;
}
/// <summary>
/// Returns only the students that the given user is assigned to.
/// In a real database, this would be a single SQL query with a JOIN
/// through student_assignments. Here we simulate it in-memory.
/// </summary>
public Task<IEnumerable<StudentSummaryDto>> GetAccessibleStudents(int userId)
{
// In-memory simulation: we cast the dummy assignment repo to get
// access to the underlying data. This is a prototype shortcut.
// In production, this would be a SQL JOIN (see below).
// We use the DummyAssignmentRepository to simulate the JOIN.
// For each student, check if the user has an active assignment.
var now = DateTime.UtcNow;
var assignmentRepo = (DummyAssignmentRepository)_assignments;
// Because we can't easily access the private static list from the
// assignment repo in this simulation, we'll use GetActiveAssignment
// for each student. This is an N+1 query in-memory — fine for a demo,
// terrible in production (which is why the real version uses a JOIN).
var results = new List<StudentSummaryDto>();
foreach (var student in _students.Where(s => !s.IsDeleted))
{
var assignment = _assignments.GetActiveAssignment(userId, student.Id).Result;
if (assignment is not null)
{
results.Add(new StudentSummaryDto
{
Id = student.Id,
Identifier = student.Identifier,
ProgramYear = student.ProgramYear,
Age = student.Age,
AssignmentType = assignment.AssignmentType.ToString()
});
}
}
// ==========================================
// REAL DATABASE VERSION (commented out):
// ==========================================
// In production, this is ONE query — no N+1 problem:
//
// return await _db.QueryAsync<StudentSummaryDto>(
// @"SELECT s.id AS Id,
// s.identifier AS Identifier,
// s.program_year AS ProgramYear,
// s.age AS Age,
// sa.assignment_type AS AssignmentType
// FROM students s
// INNER JOIN student_assignments sa ON sa.student_id = s.id
// WHERE sa.user_id = @UserId
// AND sa.is_active = TRUE
// AND sa.start_date <= CURDATE()
// AND (sa.end_date IS NULL OR sa.end_date >= CURDATE())
// AND s.is_deleted = FALSE
// ORDER BY s.identifier",
// new { UserId = userId });
//
// Notice: there is NO role check in the query. The assignments table
// already encodes who can see what — supervisors have Supervisor-type
// assignments to all students, so they naturally appear in the JOIN.
return Task.FromResult<IEnumerable<StudentSummaryDto>>(results);
}
public Task<Student?> GetById(int studentId)
{
var student = _students.FirstOrDefault(s => s.Id == studentId && !s.IsDeleted);
return Task.FromResult(student);
}
}
@@ -0,0 +1,36 @@
// =============================================================================
// IAssignmentRepository.cs
// =============================================================================
// This interface defines WHAT the assignment repository can do, without saying
// HOW it does it. In this prototype, the "how" is hardcoded dummy data.
// In production, the "how" would be a MySQL query via Dapper.
//
// WHY USE AN INTERFACE?
// ---------------------
// By coding against IAssignmentRepository (the interface) instead of a
// concrete class, you can swap implementations without changing any other code.
// Today: DummyAssignmentRepository (hardcoded). Tomorrow: DapperAssignmentRepository
// (real database). The controllers, handlers, and everything else don't change —
// they only know about the interface, not the implementation behind it.
//
// The swap happens in one place: Program.cs, where you register the service.
// =============================================================================
using RolesAssignments.Models;
namespace RolesAssignments.Data;
public interface IAssignmentRepository
{
/// <summary>
/// Looks up the active assignment between a specific user and a specific student.
/// Returns null if no active assignment exists (meaning the user has no access
/// to that student).
///
/// "Active" means:
/// - is_active is true
/// - start_date is today or earlier
/// - end_date is null (ongoing) or today or later
/// </summary>
Task<StudentAssignment?> GetActiveAssignment(int userId, int studentId);
}
@@ -0,0 +1,53 @@
// =============================================================================
// IStudentRepository.cs / IGoalRepository.cs / IProgressEntryRepository.cs
// =============================================================================
// All three repository interfaces in one file for this prototype.
// Each defines the operations available for that entity.
// =============================================================================
using RolesAssignments.Models;
namespace RolesAssignments.Data;
// ---------------------------------------------------------------------------
// STUDENT REPOSITORY
// ---------------------------------------------------------------------------
public interface IStudentRepository
{
/// <summary>
/// Returns a list of students the given user is allowed to see.
/// This is the "scoped list" query — it filters at the database level
/// so unauthorized student records never leave the data layer.
/// </summary>
Task<IEnumerable<StudentSummaryDto>> GetAccessibleStudents(int userId);
/// <summary>
/// Returns a single student by ID, or null if not found.
/// NOTE: This does NOT check authorization — the controller is responsible
/// for calling AuthorizeAsync before returning the data.
/// </summary>
Task<Student?> GetById(int studentId);
}
// ---------------------------------------------------------------------------
// GOAL REPOSITORY
// ---------------------------------------------------------------------------
public interface IGoalRepository
{
Task<IEnumerable<Goal>> GetByStudentId(int studentId);
Task<Goal?> GetById(int goalId);
Task<int> Create(int studentId, CreateGoalRequest request, int createdByUserId);
Task Update(int goalId, UpdateGoalRequest request, int updatedByUserId);
}
// ---------------------------------------------------------------------------
// PROGRESS ENTRY REPOSITORY
// ---------------------------------------------------------------------------
public interface IProgressEntryRepository
{
Task<IEnumerable<ProgressEntry>> GetByStudentId(int studentId);
Task<ProgressEntry?> GetById(int entryId);
Task<int> Create(int studentId, CreateEntryRequest request, int createdByUserId);
Task Update(int entryId, UpdateEntryRequest request, int updatedByUserId);
Task SoftDelete(int entryId, int deletedByUserId);
}
@@ -0,0 +1,78 @@
// =============================================================================
// ClaimsPrincipalExtensions.cs
// =============================================================================
// WHAT IS AN EXTENSION METHOD?
// ----------------------------
// Normally, to add a method to a class, you'd edit that class. But you can't
// edit ClaimsPrincipal — it's a built-in .NET class owned by Microsoft.
//
// C# has a feature called "extension methods" that lets you write a static
// method in a static class, but call it AS IF it were an instance method on
// another class. The trick is the `this` keyword before the first parameter:
//
// public static int GetUserId(this ClaimsPrincipal principal)
// ^^^^
// This makes it an extension method.
//
// Now you can write:
// context.User.GetUserId()
//
// Instead of:
// ClaimsPrincipalExtensions.GetUserId(context.User)
//
// Both forms compile to the exact same code. The first is just nicer to read.
// The one requirement is that the namespace containing the extension class must
// be imported with `using` wherever you want to use it.
//
// WHAT IS A ClaimsPrincipal?
// --------------------------
// When a user logs in, their identity is represented as a ClaimsPrincipal.
// It contains "claims" — key-value pairs like:
// - NameIdentifier = "1" (the user's ID)
// - Role = "Teacher"
// - Email = "rivera@school.edu"
//
// These claims are typically set during login (packed into a JWT token or
// cookie) and are available on every subsequent request via HttpContext.User.
//
// The extension methods below make it easy to extract specific values without
// writing the same verbose claim-lookup code in every controller and handler.
// =============================================================================
using System.Security.Claims;
namespace RolesAssignments.Extensions;
public static class ClaimsPrincipalExtensions
{
/// <summary>
/// Extracts the user's numeric ID from their claims.
/// The claim is stored under ClaimTypes.NameIdentifier, which is a
/// standard claim type meaning "the unique identifier for this user."
/// </summary>
public static int GetUserId(this ClaimsPrincipal principal)
{
// FindFirstValue searches through the claims for the first one
// with the given type, and returns its string value.
var claim = principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (claim is null)
throw new InvalidOperationException(
"User ID claim not found. This means the authentication " +
"middleware didn't set up the user's claims properly.");
// Claims are always strings, so we parse to int.
return int.Parse(claim);
}
/// <summary>
/// Extracts the user's role from their claims (e.g., "Teacher").
/// </summary>
public static string GetRole(this ClaimsPrincipal principal)
{
return principal.FindFirstValue(ClaimTypes.Role)
?? throw new InvalidOperationException(
"Role claim not found. This means the authentication " +
"middleware didn't set up the user's claims properly.");
}
}
@@ -0,0 +1,36 @@
// =============================================================================
// FakeAuthHeaderFilter.cs
// =============================================================================
// This is a Swagger/OpenAPI filter that automatically adds an "X-User-Id"
// header input field to every endpoint in the Swagger UI.
//
// Without this, you'd have to manually type the header name and value each
// time you test an endpoint. With this, every endpoint shows a pre-labeled
// input box where you just type 1, 2, or 3.
//
// HOW IT WORKS:
// Swashbuckle (the Swagger library) calls this filter for every endpoint
// it discovers. We add a header parameter to each one.
// =============================================================================
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace RolesAssignments.Middleware;
public class FakeAuthHeaderFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
// Add the X-User-Id header parameter to every endpoint
operation.Parameters ??= new List<OpenApiParameter>();
operation.Parameters.Add(new OpenApiParameter
{
Name = "X-User-Id",
In = ParameterLocation.Header,
Required = false,
Description = "Fake user ID: 1 = Ms. Rivera (Teacher), 2 = Mr. Daniels (Paraeducator), 3 = Dr. Patel (Supervisor)",
Schema = new OpenApiSchema { Type = "integer" }
});
}
}
@@ -0,0 +1,100 @@
// =============================================================================
// FakeAuthMiddleware.cs
// =============================================================================
// IN A REAL APPLICATION, this would be replaced by JWT bearer authentication,
// cookie authentication, or Identity. The user would log in once, receive a
// token or cookie, and every subsequent request would carry their identity.
//
// FOR THIS PROTOTYPE, we simulate login by reading an HTTP header:
//
// X-User-Id: 1 → You're Ms. Rivera (Teacher)
// X-User-Id: 2 → You're Mr. Daniels (Paraeducator)
// X-User-Id: 3 → You're Dr. Patel (Supervisor)
//
// This middleware runs BEFORE every request reaches a controller. It reads
// the header, looks up the user in a hardcoded list, and builds a
// ClaimsPrincipal — the same object that real authentication produces.
//
// This means the rest of the application (controllers, authorization handlers)
// works EXACTLY as it would with real authentication. The only fake part is
// HOW the user identity gets established.
//
// WHAT IS MIDDLEWARE?
// -------------------
// Middleware is code that runs in a pipeline for every HTTP request.
// Think of it like a series of checkpoints:
// Request → [Middleware A] → [Middleware B] → [Controller] → Response
// Each middleware can inspect/modify the request, call the next one, or
// short-circuit the pipeline (e.g., return 401 without ever reaching the
// controller).
// =============================================================================
using System.Security.Claims;
namespace RolesAssignments.Middleware;
public class FakeAuthMiddleware
{
// The next middleware in the pipeline. Middleware is like a chain —
// each piece holds a reference to the next one and decides whether
// to pass the request along.
private readonly RequestDelegate _next;
// Hardcoded user directory. In a real app, this would be a database lookup
// during login, and the resulting claims would be stored in a JWT token.
private static readonly Dictionary<int, (string Name, string Role)> _users = new()
{
{ 1, ("Ms. Rivera", "Teacher") },
{ 2, ("Mr. Daniels", "Paraeducator") },
{ 3, ("Dr. Patel", "Supervisor") },
};
public FakeAuthMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// Try to read the X-User-Id header from the incoming request.
// If it's missing or invalid, we let the request through without
// setting up a user — the [Authorize] attribute on controllers
// will reject it with a 401 Unauthorized.
if (context.Request.Headers.TryGetValue("X-User-Id", out var userIdHeader)
&& int.TryParse(userIdHeader, out var userId)
&& _users.TryGetValue(userId, out var user))
{
// Build a list of "claims" — key-value pairs that describe the user.
// These are the same claims that would be embedded in a JWT token
// or stored in an authentication cookie in a real system.
var claims = new[]
{
// NameIdentifier = the user's unique ID (what GetUserId() reads)
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
// Name = the user's display name
new Claim(ClaimTypes.Name, user.Name),
// Role = what kind of user they are (what GetRole() reads)
new Claim(ClaimTypes.Role, user.Role),
};
// Create a "ClaimsIdentity" (a collection of claims tied to an
// authentication scheme). The second parameter "FakeAuth" is the
// authentication scheme name — in a real app this would be
// "Bearer" (for JWT) or "Cookies" (for cookie auth).
var identity = new ClaimsIdentity(claims, "FakeAuth");
// Wrap it in a ClaimsPrincipal and set it on the HTTP context.
// From this point on, any code that reads HttpContext.User
// (including controllers and authorization handlers) will see
// this user as "logged in."
context.User = new ClaimsPrincipal(identity);
}
// Pass the request to the next middleware in the pipeline.
// If we didn't set up a user above, context.User will be an
// anonymous/unauthenticated principal, and [Authorize] will block it.
await _next(context);
}
}
@@ -0,0 +1,39 @@
// =============================================================================
// AssignmentType.cs
// =============================================================================
// This enum represents the RELATIONSHIP between a user and a student.
//
// Think of it this way: a user's ROLE (Teacher, Paraeducator, Supervisor) tells
// you WHAT they are. Their ASSIGNMENT TYPE tells you HOW they're connected to a
// specific student.
//
// For example, a Teacher might be the "PrimaryTeacher" for Student A, but have
// "TemporaryCoverage" for Student B (e.g., covering for a colleague on leave).
//
// This is stored in the `student_assignments` table in the database —
// each row says "User X is connected to Student Y as [this type]."
// =============================================================================
namespace RolesAssignments.Models;
public enum AssignmentType
{
// The main teacher responsible for this student.
// Has full read/write access to goals, entries, sensitive notes, etc.
PrimaryTeacher,
// A paraeducator (teaching assistant) assigned to help with this student.
// Can view the student and add progress entries, but can only edit/delete
// their OWN entries — not entries made by someone else.
Paraeducator,
// A supervisor (admin/principal) overseeing this student.
// Has read-only access: can view everything and generate reports,
// but cannot create, edit, or delete anything.
Supervisor,
// A teacher temporarily covering for the primary teacher (e.g., sick leave).
// In this prototype, treated similarly to PrimaryTeacher for simplicity,
// but in a real system you might limit what they can do.
TemporaryCoverage
}
+28
View File
@@ -0,0 +1,28 @@
// =============================================================================
// Goal.cs
// =============================================================================
// Represents a learning goal that belongs to a specific student.
// For example: "Student will read 20 words per minute by end of semester."
//
// Only a PrimaryTeacher (or TemporaryCoverage) can create or edit goals.
// Everyone else assigned to the student can VIEW them, but not change them.
// =============================================================================
namespace RolesAssignments.Models;
public class Goal
{
public int Id { get; set; }
// The student this goal belongs to.
// This is the foreign key back to the students table.
public int StudentId { get; set; }
// The text of the goal itself
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
// Who created this goal and when
public int CreatedByUserId { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,41 @@
// =============================================================================
// ProgressEntry.cs
// =============================================================================
// Represents a single log entry tracking a student's progress toward a goal.
// For example: "Feb 19 — Student read 15 words per minute today."
//
// This is the entity that demonstrates the TWO-LAYER authorization check:
//
// Layer 1 (Student-level): "Is this user assigned to the student at all?"
// Layer 2 (Entry-level): "Does this user own THIS specific entry?"
//
// Teachers can edit/delete ANY entry for their assigned students.
// Paraeducators can only edit/delete entries THEY created.
// Supervisors cannot edit/delete anything.
//
// The key field for Layer 2 is `CreatedByUserId` — it tells us who wrote
// this entry, which determines ownership.
// =============================================================================
namespace RolesAssignments.Models;
public class ProgressEntry
{
public int Id { get; set; }
// Which student this entry is about
public int StudentId { get; set; }
// The content of the progress note
public string Notes { get; set; } = string.Empty;
// WHO created this entry — this is the field used for ownership checks.
// When a paraeducator tries to edit an entry, we compare this field
// against the current user's ID to decide if they're allowed.
public int CreatedByUserId { get; set; }
public DateTime CreatedAt { get; set; }
// Soft-delete: mark as deleted instead of actually removing the row
public bool IsDeleted { get; set; }
}
@@ -0,0 +1,41 @@
// =============================================================================
// Requests.cs
// =============================================================================
// These are the "request" DTOs — the shapes of data that the API expects to
// RECEIVE from the caller when creating or updating resources.
//
// They're intentionally simple: just the fields the caller controls.
// Things like Id, CreatedByUserId, and CreatedAt are set by the server,
// not by the caller.
//
// In a real app, you'd add validation attributes here (e.g., [Required],
// [MaxLength(500)]) to enforce rules before the data reaches your logic.
// =============================================================================
namespace RolesAssignments.Models;
// --- Goal Requests ---
public class CreateGoalRequest
{
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
}
public class UpdateGoalRequest
{
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
}
// --- Progress Entry Requests ---
public class CreateEntryRequest
{
public string Notes { get; set; } = string.Empty;
}
public class UpdateEntryRequest
{
public string Notes { get; set; } = string.Empty;
}
@@ -0,0 +1,30 @@
// =============================================================================
// Student.cs
// =============================================================================
// A simple student entity. In a real app this would map to the `students` table.
//
// Note: students don't "belong" to a teacher directly. Instead, the connection
// is made through the `student_assignments` table (see StudentAssignment.cs).
// This keeps the data model flexible — a student can have multiple adults
// working with them, each with a different level of access.
// =============================================================================
namespace RolesAssignments.Models;
public class Student
{
public int Id { get; set; }
// A human-readable label like "Student A" or an internal identifier.
// In a real system this might be a school-issued ID number.
public string Identifier { get; set; } = string.Empty;
// e.g., "2025-2026"
public string ProgramYear { get; set; } = string.Empty;
public int Age { get; set; }
// Soft-delete flag. Instead of removing rows from the database,
// we mark them as deleted so we can still reference historical data.
public bool IsDeleted { get; set; }
}
@@ -0,0 +1,49 @@
// =============================================================================
// StudentAssignment.cs
// =============================================================================
// This class represents a single row from the `student_assignments` table.
//
// It is THE MOST IMPORTANT piece of data in the entire authorization system.
// Every permission decision boils down to: "Does this user have an active
// assignment to this student, and if so, what type is it?"
//
// The corresponding database table looks like this:
//
// CREATE TABLE student_assignments (
// id INT PRIMARY KEY AUTO_INCREMENT,
// user_id INT NOT NULL,
// student_id INT NOT NULL,
// assignment_type ENUM('PrimaryTeacher','Paraeducator','Supervisor','TemporaryCoverage') NOT NULL,
// start_date DATE NOT NULL,
// end_date DATE NULL, -- NULL means "no end date" (ongoing)
// is_active BOOLEAN NOT NULL DEFAULT TRUE,
// FOREIGN KEY (user_id) REFERENCES users(id),
// FOREIGN KEY (student_id) REFERENCES students(id)
// );
// =============================================================================
namespace RolesAssignments.Models;
public class StudentAssignment
{
public int Id { get; set; }
// Which user is assigned
public int UserId { get; set; }
// Which student they're assigned to
public int StudentId { get; set; }
// What kind of assignment this is (see AssignmentType.cs for details)
public AssignmentType AssignmentType { get; set; }
// When this assignment started (e.g., the beginning of the school year)
public DateTime StartDate { get; set; }
// When this assignment ends. NULL means it's ongoing with no planned end date.
public DateTime? EndDate { get; set; }
// A quick on/off switch. If a student transfers classes, you can set this
// to false instead of deleting the row — preserving the history.
public bool IsActive { get; set; }
}
@@ -0,0 +1,27 @@
// =============================================================================
// StudentSummaryDto.cs
// =============================================================================
// A "DTO" (Data Transfer Object) is a lightweight object designed specifically
// for sending data over the wire (in API responses). It contains only the
// fields the caller needs — no internal details, no navigation properties.
//
// This DTO is returned by the "Get My Students" list endpoint. It includes
// the student's basic info plus the assignment type, so the frontend knows
// what kind of access the current user has.
// =============================================================================
namespace RolesAssignments.Models;
public class StudentSummaryDto
{
public int Id { get; set; }
public string Identifier { get; set; } = string.Empty;
public string ProgramYear { get; set; } = string.Empty;
public int Age { get; set; }
// This tells the frontend what the current user's relationship to this
// student is. For supervisors who see all students, this might be null
// for students they don't have a direct assignment to (though in this
// prototype, supervisors always have Supervisor-type assignments).
public string? AssignmentType { get; set; }
}
+163
View File
@@ -0,0 +1,163 @@
// =============================================================================
// Program.cs
// =============================================================================
// This is the application's entry point — where everything gets wired together.
//
// It does three things:
// 1. REGISTERS SERVICES with the dependency injection (DI) container
// 2. CONFIGURES the HTTP pipeline (middleware order matters!)
// 3. STARTS the web server
//
// DEPENDENCY INJECTION (DI) IN PLAIN ENGLISH:
// -------------------------------------------
// Instead of creating objects yourself (e.g., `new DummyGoalRepository()`),
// you tell the framework: "whenever something asks for IGoalRepository,
// give it a DummyGoalRepository." Then your controllers just declare
// "I need an IGoalRepository" in their constructor, and the framework
// automatically provides one.
//
// This is powerful because:
// - You can swap implementations in ONE place (here) without touching
// any controllers or handlers.
// - Each class only knows about the INTERFACE, not the concrete class.
// - Testing is easier — you can provide fake implementations.
//
// SERVICE LIFETIMES:
// - AddSingleton: ONE instance for the entire application lifetime.
// - AddScoped: ONE instance PER HTTP REQUEST (created at the start
// of the request, disposed at the end).
// - AddTransient: A NEW instance every time it's requested.
//
// We use AddSingleton for repositories because our dummy data is static
// (shared across all requests). In production with a real database, you'd
// use AddScoped so each request gets its own database connection.
// =============================================================================
using Microsoft.AspNetCore.Authorization;
using RolesAssignments.Authorization.Handlers;
using RolesAssignments.Data;
using RolesAssignments.Middleware;
var builder = WebApplication.CreateBuilder(args);
// =============================================================================
// SERVICE REGISTRATION
// =============================================================================
// ---- Controllers ----
// Tells the framework to discover and register all [ApiController] classes.
builder.Services.AddControllers();
// ---- Authentication (Fake) ----
// ASP.NET Core's [Authorize] attribute needs a registered authentication scheme
// so it knows HOW to reject unauthenticated requests (the "challenge").
// Even though our FakeAuthMiddleware does the real work of setting up the user,
// we still need to register a scheme name so the framework doesn't complain.
//
// In production, you'd replace this with:
// builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
// .AddJwtBearer(options => { /* JWT config */ });
builder.Services.AddAuthentication("FakeAuth")
.AddCookie("FakeAuth", options =>
{
// These settings don't really matter for our prototype since
// FakeAuthMiddleware handles everything. But the framework requires
// a registered handler for the scheme to exist.
options.Events.OnRedirectToLogin = context =>
{
// Instead of redirecting to a login page (default cookie behavior),
// return a clean 401 status code — appropriate for an API.
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
});
// ---- Authorization ----
// Registers the core authorization services (IAuthorizationService, etc.).
// This is what makes AuthorizeAsync() available for injection.
builder.Services.AddAuthorizationCore();
// ---- Authorization Handlers ----
// Register our custom authorization handlers. The framework will discover
// them when AuthorizeAsync() is called and route to the correct handler
// based on the requirement type and resource type.
//
// AddScoped means one instance per request — this is appropriate because
// each handler may hold per-request state (though ours don't).
builder.Services.AddScoped<IAuthorizationHandler, StudentAuthorizationHandler>();
builder.Services.AddScoped<IAuthorizationHandler, ProgressEntryAuthorizationHandler>();
// ---- Repositories (Data Access) ----
// Here's where the "swap" happens. Today we register dummy implementations.
// To switch to a real database, you'd change these lines to:
//
// builder.Services.AddScoped<IAssignmentRepository, DapperAssignmentRepository>();
// builder.Services.AddScoped<IStudentRepository, DapperStudentRepository>();
// builder.Services.AddScoped<IGoalRepository, DapperGoalRepository>();
// builder.Services.AddScoped<IProgressEntryRepository, DapperProgressEntryRepository>();
//
// ...and nothing else in the entire codebase would change.
builder.Services.AddSingleton<IAssignmentRepository, DummyAssignmentRepository>();
builder.Services.AddSingleton<IStudentRepository, DummyStudentRepository>();
builder.Services.AddSingleton<IGoalRepository, DummyGoalRepository>();
builder.Services.AddSingleton<IProgressEntryRepository, DummyProgressEntryRepository>();
// ---- OpenAPI / Swagger UI ----
// AddEndpointsApiExplorer lets Swagger discover your endpoints.
// AddSwaggerGen generates the OpenAPI spec AND the interactive test page.
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
// This filter adds the "X-User-Id" header input to every endpoint in the
// Swagger UI, so you don't have to type the header name manually each time.
options.OperationFilter<FakeAuthHeaderFilter>();
});
var app = builder.Build();
// =============================================================================
// MIDDLEWARE PIPELINE
// =============================================================================
// Middleware runs in ORDER. Each request flows through them like this:
//
// Request
// → HTTPS Redirection
// → FakeAuthMiddleware (sets up the user)
// → Authorization (checks [Authorize] attributes)
// → Controller (your endpoint code)
// Response
//
// ORDER MATTERS! FakeAuthMiddleware must run BEFORE UseAuthorization(),
// because authorization needs to know who the user is before it can decide
// if they're allowed in. If you swap them, every request would be denied
// because the user would still be "anonymous" when authorization runs.
if (app.Environment.IsDevelopment())
{
// Serves the OpenAPI JSON spec at /swagger/v1/swagger.json
app.UseSwagger();
// Serves the interactive Swagger UI at /swagger
// To test as different users, click "Try it out" on any endpoint,
// then add the header: X-User-Id with value 1, 2, or 3.
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// Register the authentication middleware (needed to activate the "FakeAuth" scheme).
app.UseAuthentication();
// Our fake authentication middleware runs AFTER UseAuthentication() and OVERRIDES
// the user identity based on the X-User-Id header. In production, you'd remove
// this line entirely — UseAuthentication() above would handle everything via JWT.
app.UseMiddleware<FakeAuthMiddleware>();
// ASP.NET Core's built-in authorization middleware. This is what enforces
// [Authorize] attributes on controllers and calls our handlers.
app.UseAuthorization();
// Maps controller routes (e.g., /api/students, /api/students/101/goals)
app.MapControllers();
app.Run();
@@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5079",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7158;http://localhost:5079",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ActiveDebugProfile>https</ActiveDebugProfile>
</PropertyGroup>
</Project>
@@ -0,0 +1,6 @@
@RolesAssignments_HostAddress = http://localhost:5079
GET {{RolesAssignments_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,25 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36429.23 d17.14
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RolesAssignments", "RolesAssignments.csproj", "{8668BDD8-B4B1-4A94-A3E2-CB140CA3E516}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{8668BDD8-B4B1-4A94-A3E2-CB140CA3E516}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8668BDD8-B4B1-4A94-A3E2-CB140CA3E516}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8668BDD8-B4B1-4A94-A3E2-CB140CA3E516}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8668BDD8-B4B1-4A94-A3E2-CB140CA3E516}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {4E8E1296-D0D3-4A0C-9D57-62CD45B06C29}
EndGlobalSection
EndGlobal
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+27
View File
@@ -0,0 +1,27 @@
# User → Student Assignments
| User (X-User-Id) | Student 101 (A) | Student 102 (B) | Student 103 (C) | Student 104 (D) |
|----------------------|--------------------|--------------------|------------------|------------------|
| **1 — Ms. Rivera** | ✅ PrimaryTeacher | ✅ PrimaryTeacher | ❌ No access | ❌ No access |
| **2 — Mr. Daniels** | ✅ Paraeducator | ❌ No access | ❌ No access | ❌ No access |
| **3 — Dr. Patel** | ✅ Supervisor | ✅ Supervisor | ✅ Supervisor | ✅ Supervisor |
# What Each Assignment Type Can Do
| Action | PrimaryTeacher | Paraeducator | Supervisor |
|------------------------|------------------|--------------------|-------------|
| View student | ✅ | ✅ | ✅ |
| Create goal | ✅ | ❌ | ❌ |
| Edit goal | ✅ | ❌ | ❌ |
| Add progress entry | ✅ | ✅ | ❌ |
| Edit progress entry | ✅ any entry | ✅ own entries only | ❌ |
| Delete progress entry | ✅ any entry | ✅ own entries only | ❌ |
# Quick Test Scenarios
- **User 1, GET /api/students** → sees A & B (not C or D)
- **User 2, GET /api/students** → sees A only
- **User 3, GET /api/students** → sees all four (A, B, C, D)
- **User 2, POST goal for student 101** → 403 Forbidden (paras can't create goals)
- **User 2, PUT entry 2 for student 101** → ✅ (it's his own entry)
- **User 2, PUT entry 1 for student 101** → 403 (entry 1 was created by user 1)