From f178add2be9cbc16b7fe5255c5519c6799e1959e Mon Sep 17 00:00:00 2001 From: ivan-pelly Date: Thu, 19 Feb 2026 07:19:50 -0800 Subject: [PATCH] Prototype illustrative role assignments project --- .gitignore | 3 + .../ProgressEntryAuthorizationHandler.cs | 97 ++++++++++ .../Handlers/StudentAuthorizationHandler.cs | 142 ++++++++++++++ .../Authorization/Operations.cs | 70 +++++++ .../Resources/ProgressEntryResource.cs | 21 +++ .../Resources/StudentResource.cs | 22 +++ .../Controllers/GoalsController.cs | 127 +++++++++++++ .../Controllers/ProgressEntriesController.cs | 177 ++++++++++++++++++ .../Controllers/StudentsController.cs | 108 +++++++++++ .../Data/DummyAssignmentRepository.cs | 127 +++++++++++++ .../Data/DummyGoalRepository.cs | 121 ++++++++++++ .../Data/DummyProgressEntryRepository.cs | 151 +++++++++++++++ .../Data/DummyStudentRepository.cs | 112 +++++++++++ .../Data/IAssignmentRepository.cs | 36 ++++ .../RolesAssignments/Data/IRepositories.cs | 53 ++++++ .../Extensions/ClaimsPrincipalExtensions.cs | 78 ++++++++ .../Middleware/FakeAuthHeaderFilter.cs | 36 ++++ .../Middleware/FakeAuthMiddleware.cs | 100 ++++++++++ .../RolesAssignments/Models/AssignmentType.cs | 39 ++++ prototype/RolesAssignments/Models/Goal.cs | 28 +++ .../RolesAssignments/Models/ProgressEntry.cs | 41 ++++ prototype/RolesAssignments/Models/Requests.cs | 41 ++++ prototype/RolesAssignments/Models/Student.cs | 30 +++ .../Models/StudentAssignment.cs | 49 +++++ .../Models/StudentSummaryDto.cs | 27 +++ prototype/RolesAssignments/Program.cs | 163 ++++++++++++++++ .../Properties/launchSettings.json | 25 +++ .../RolesAssignments/RolesAssignments.csproj | 13 ++ .../RolesAssignments.csproj.user | 6 + .../RolesAssignments/RolesAssignments.http | 6 + .../RolesAssignments/RolesAssignments.sln | 25 +++ .../appsettings.Development.json | 8 + prototype/RolesAssignments/appsettings.json | 9 + prototype/RolesAssignments/readme.md | 27 +++ 34 files changed, 2118 insertions(+) create mode 100644 prototype/RolesAssignments/Authorization/Handlers/ProgressEntryAuthorizationHandler.cs create mode 100644 prototype/RolesAssignments/Authorization/Handlers/StudentAuthorizationHandler.cs create mode 100644 prototype/RolesAssignments/Authorization/Operations.cs create mode 100644 prototype/RolesAssignments/Authorization/Resources/ProgressEntryResource.cs create mode 100644 prototype/RolesAssignments/Authorization/Resources/StudentResource.cs create mode 100644 prototype/RolesAssignments/Controllers/GoalsController.cs create mode 100644 prototype/RolesAssignments/Controllers/ProgressEntriesController.cs create mode 100644 prototype/RolesAssignments/Controllers/StudentsController.cs create mode 100644 prototype/RolesAssignments/Data/DummyAssignmentRepository.cs create mode 100644 prototype/RolesAssignments/Data/DummyGoalRepository.cs create mode 100644 prototype/RolesAssignments/Data/DummyProgressEntryRepository.cs create mode 100644 prototype/RolesAssignments/Data/DummyStudentRepository.cs create mode 100644 prototype/RolesAssignments/Data/IAssignmentRepository.cs create mode 100644 prototype/RolesAssignments/Data/IRepositories.cs create mode 100644 prototype/RolesAssignments/Extensions/ClaimsPrincipalExtensions.cs create mode 100644 prototype/RolesAssignments/Middleware/FakeAuthHeaderFilter.cs create mode 100644 prototype/RolesAssignments/Middleware/FakeAuthMiddleware.cs create mode 100644 prototype/RolesAssignments/Models/AssignmentType.cs create mode 100644 prototype/RolesAssignments/Models/Goal.cs create mode 100644 prototype/RolesAssignments/Models/ProgressEntry.cs create mode 100644 prototype/RolesAssignments/Models/Requests.cs create mode 100644 prototype/RolesAssignments/Models/Student.cs create mode 100644 prototype/RolesAssignments/Models/StudentAssignment.cs create mode 100644 prototype/RolesAssignments/Models/StudentSummaryDto.cs create mode 100644 prototype/RolesAssignments/Program.cs create mode 100644 prototype/RolesAssignments/Properties/launchSettings.json create mode 100644 prototype/RolesAssignments/RolesAssignments.csproj create mode 100644 prototype/RolesAssignments/RolesAssignments.csproj.user create mode 100644 prototype/RolesAssignments/RolesAssignments.http create mode 100644 prototype/RolesAssignments/RolesAssignments.sln create mode 100644 prototype/RolesAssignments/appsettings.Development.json create mode 100644 prototype/RolesAssignments/appsettings.json create mode 100644 prototype/RolesAssignments/readme.md diff --git a/.gitignore b/.gitignore index be47843..91c9ff8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .DS_Store .env +/prototype/RolesAssignments/.vs +/prototype/RolesAssignments/bin +/prototype/RolesAssignments/obj diff --git a/prototype/RolesAssignments/Authorization/Handlers/ProgressEntryAuthorizationHandler.cs b/prototype/RolesAssignments/Authorization/Handlers/ProgressEntryAuthorizationHandler.cs new file mode 100644 index 0000000..4edfd5d --- /dev/null +++ b/prototype/RolesAssignments/Authorization/Handlers/ProgressEntryAuthorizationHandler.cs @@ -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 +// ^^^^^^^^^^^^^^^^^^^^^ +// 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; + } + } +} diff --git a/prototype/RolesAssignments/Authorization/Handlers/StudentAuthorizationHandler.cs b/prototype/RolesAssignments/Authorization/Handlers/StudentAuthorizationHandler.cs new file mode 100644 index 0000000..7bcb794 --- /dev/null +++ b/prototype/RolesAssignments/Authorization/Handlers/StudentAuthorizationHandler.cs @@ -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. +// 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 +{ + // 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. + } + } +} diff --git a/prototype/RolesAssignments/Authorization/Operations.cs b/prototype/RolesAssignments/Authorization/Operations.cs new file mode 100644 index 0000000..a62ebc0 --- /dev/null +++ b/prototype/RolesAssignments/Authorization/Operations.cs @@ -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) }; +} diff --git a/prototype/RolesAssignments/Authorization/Resources/ProgressEntryResource.cs b/prototype/RolesAssignments/Authorization/Resources/ProgressEntryResource.cs new file mode 100644 index 0000000..78e4571 --- /dev/null +++ b/prototype/RolesAssignments/Authorization/Resources/ProgressEntryResource.cs @@ -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); diff --git a/prototype/RolesAssignments/Authorization/Resources/StudentResource.cs b/prototype/RolesAssignments/Authorization/Resources/StudentResource.cs new file mode 100644 index 0000000..d40d760 --- /dev/null +++ b/prototype/RolesAssignments/Authorization/Resources/StudentResource.cs @@ -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); diff --git a/prototype/RolesAssignments/Controllers/GoalsController.cs b/prototype/RolesAssignments/Controllers/GoalsController.cs new file mode 100644 index 0000000..a462127 --- /dev/null +++ b/prototype/RolesAssignments/Controllers/GoalsController.cs @@ -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; + } + + /// + /// GET /api/students/{studentId}/goals + /// Lists all goals for a student. Requires ViewStudent permission. + /// + /// Any assigned user (teacher, para, supervisor) can view goals. + /// + [HttpGet] + public async Task 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); + } + + /// + /// GET /api/students/{studentId}/goals/{goalId} + /// Returns a single goal. Requires ViewStudent permission. + /// + [HttpGet("{goalId}")] + public async Task 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); + } + + /// + /// 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. + /// + [HttpPost] + public async Task 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); + } + + /// + /// PUT /api/students/{studentId}/goals/{goalId} + /// Updates an existing goal. Requires EditGoal permission. + /// + /// Only PrimaryTeacher and TemporaryCoverage assignments grant this. + /// + [HttpPut("{goalId}")] + public async Task 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(); + } +} diff --git a/prototype/RolesAssignments/Controllers/ProgressEntriesController.cs b/prototype/RolesAssignments/Controllers/ProgressEntriesController.cs new file mode 100644 index 0000000..89adb03 --- /dev/null +++ b/prototype/RolesAssignments/Controllers/ProgressEntriesController.cs @@ -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; + } + + /// + /// GET /api/students/{studentId}/entries + /// Lists all progress entries for a student. Requires ViewStudent permission. + /// + [HttpGet] + public async Task 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); + } + + /// + /// GET /api/students/{studentId}/entries/{entryId} + /// Returns a single progress entry. Requires ViewStudent permission. + /// + [HttpGet("{entryId}")] + public async Task 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); + } + + /// + /// POST /api/students/{studentId}/entries + /// Creates a new progress entry. Requires AddProgressEntry permission. + /// + /// Teachers and Paraeducators can both add entries. + /// Supervisors cannot. + /// + [HttpPost] + public async Task 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); + } + + /// + /// 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 + /// + [HttpPut("{entryId}")] + public async Task 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(); + } + + /// + /// 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. + /// + [HttpDelete("{entryId}")] + public async Task 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(); + } +} diff --git a/prototype/RolesAssignments/Controllers/StudentsController.cs b/prototype/RolesAssignments/Controllers/StudentsController.cs new file mode 100644 index 0000000..8b76f08 --- /dev/null +++ b/prototype/RolesAssignments/Controllers/StudentsController.cs @@ -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; + } + + /// + /// 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) + /// + [HttpGet] + public async Task 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); + } + + /// + /// 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. + /// + [HttpGet("{studentId}")] + public async Task 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); + } +} diff --git a/prototype/RolesAssignments/Data/DummyAssignmentRepository.cs b/prototype/RolesAssignments/Data/DummyAssignmentRepository.cs new file mode 100644 index 0000000..57a0fd6 --- /dev/null +++ b/prototype/RolesAssignments/Data/DummyAssignmentRepository.cs @@ -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 _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 + }, + }; + + /// + /// Finds an active assignment between the given user and student. + /// Returns null if no active assignment exists. + /// + public Task 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 GetActiveAssignment(int userId, int studentId) + // { + // return await _db.QueryFirstOrDefaultAsync( + // @"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); + } +} diff --git a/prototype/RolesAssignments/Data/DummyGoalRepository.cs b/prototype/RolesAssignments/Data/DummyGoalRepository.cs new file mode 100644 index 0000000..abbf35a --- /dev/null +++ b/prototype/RolesAssignments/Data/DummyGoalRepository.cs @@ -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 _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> GetByStudentId(int studentId) + { + var goals = _goals.Where(g => g.StudentId == studentId); + + // ========================================== + // REAL DATABASE VERSION (commented out): + // ========================================== + // return await _db.QueryAsync( + // @"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 GetById(int goalId) + { + var goal = _goals.FirstOrDefault(g => g.Id == goalId); + return Task.FromResult(goal); + } + + public Task 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( + // @"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; + } +} diff --git a/prototype/RolesAssignments/Data/DummyProgressEntryRepository.cs b/prototype/RolesAssignments/Data/DummyProgressEntryRepository.cs new file mode 100644 index 0000000..e51287e --- /dev/null +++ b/prototype/RolesAssignments/Data/DummyProgressEntryRepository.cs @@ -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 _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> GetByStudentId(int studentId) + { + var entries = _entries.Where(e => e.StudentId == studentId && !e.IsDeleted); + + // ========================================== + // REAL DATABASE VERSION (commented out): + // ========================================== + // return await _db.QueryAsync( + // @"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 GetById(int entryId) + { + var entry = _entries.FirstOrDefault(e => e.Id == entryId && !e.IsDeleted); + return Task.FromResult(entry); + } + + public Task 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( + // @"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; + } + + /// + /// Soft-delete: marks the entry as deleted instead of removing it. + /// The row stays in the database for auditing purposes. + /// + 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; + } +} diff --git a/prototype/RolesAssignments/Data/DummyStudentRepository.cs b/prototype/RolesAssignments/Data/DummyStudentRepository.cs new file mode 100644 index 0000000..e72981c --- /dev/null +++ b/prototype/RolesAssignments/Data/DummyStudentRepository.cs @@ -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 _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; + } + + /// + /// 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. + /// + public Task> 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(); + 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( + // @"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>(results); + } + + public Task GetById(int studentId) + { + var student = _students.FirstOrDefault(s => s.Id == studentId && !s.IsDeleted); + return Task.FromResult(student); + } +} diff --git a/prototype/RolesAssignments/Data/IAssignmentRepository.cs b/prototype/RolesAssignments/Data/IAssignmentRepository.cs new file mode 100644 index 0000000..c646457 --- /dev/null +++ b/prototype/RolesAssignments/Data/IAssignmentRepository.cs @@ -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 +{ + /// + /// 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 + /// + Task GetActiveAssignment(int userId, int studentId); +} diff --git a/prototype/RolesAssignments/Data/IRepositories.cs b/prototype/RolesAssignments/Data/IRepositories.cs new file mode 100644 index 0000000..69fd843 --- /dev/null +++ b/prototype/RolesAssignments/Data/IRepositories.cs @@ -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 +{ + /// + /// 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. + /// + Task> GetAccessibleStudents(int userId); + + /// + /// 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. + /// + Task GetById(int studentId); +} + +// --------------------------------------------------------------------------- +// GOAL REPOSITORY +// --------------------------------------------------------------------------- +public interface IGoalRepository +{ + Task> GetByStudentId(int studentId); + Task GetById(int goalId); + Task Create(int studentId, CreateGoalRequest request, int createdByUserId); + Task Update(int goalId, UpdateGoalRequest request, int updatedByUserId); +} + +// --------------------------------------------------------------------------- +// PROGRESS ENTRY REPOSITORY +// --------------------------------------------------------------------------- +public interface IProgressEntryRepository +{ + Task> GetByStudentId(int studentId); + Task GetById(int entryId); + Task Create(int studentId, CreateEntryRequest request, int createdByUserId); + Task Update(int entryId, UpdateEntryRequest request, int updatedByUserId); + Task SoftDelete(int entryId, int deletedByUserId); +} diff --git a/prototype/RolesAssignments/Extensions/ClaimsPrincipalExtensions.cs b/prototype/RolesAssignments/Extensions/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..7f61edd --- /dev/null +++ b/prototype/RolesAssignments/Extensions/ClaimsPrincipalExtensions.cs @@ -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 +{ + /// + /// 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." + /// + 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); + } + + /// + /// Extracts the user's role from their claims (e.g., "Teacher"). + /// + 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."); + } +} diff --git a/prototype/RolesAssignments/Middleware/FakeAuthHeaderFilter.cs b/prototype/RolesAssignments/Middleware/FakeAuthHeaderFilter.cs new file mode 100644 index 0000000..925765a --- /dev/null +++ b/prototype/RolesAssignments/Middleware/FakeAuthHeaderFilter.cs @@ -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(); + 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" } + }); + } +} diff --git a/prototype/RolesAssignments/Middleware/FakeAuthMiddleware.cs b/prototype/RolesAssignments/Middleware/FakeAuthMiddleware.cs new file mode 100644 index 0000000..7f8dbd5 --- /dev/null +++ b/prototype/RolesAssignments/Middleware/FakeAuthMiddleware.cs @@ -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 _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); + } +} diff --git a/prototype/RolesAssignments/Models/AssignmentType.cs b/prototype/RolesAssignments/Models/AssignmentType.cs new file mode 100644 index 0000000..784759b --- /dev/null +++ b/prototype/RolesAssignments/Models/AssignmentType.cs @@ -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 +} diff --git a/prototype/RolesAssignments/Models/Goal.cs b/prototype/RolesAssignments/Models/Goal.cs new file mode 100644 index 0000000..46da626 --- /dev/null +++ b/prototype/RolesAssignments/Models/Goal.cs @@ -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; } +} diff --git a/prototype/RolesAssignments/Models/ProgressEntry.cs b/prototype/RolesAssignments/Models/ProgressEntry.cs new file mode 100644 index 0000000..2fa5c63 --- /dev/null +++ b/prototype/RolesAssignments/Models/ProgressEntry.cs @@ -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; } +} diff --git a/prototype/RolesAssignments/Models/Requests.cs b/prototype/RolesAssignments/Models/Requests.cs new file mode 100644 index 0000000..0719ced --- /dev/null +++ b/prototype/RolesAssignments/Models/Requests.cs @@ -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; +} diff --git a/prototype/RolesAssignments/Models/Student.cs b/prototype/RolesAssignments/Models/Student.cs new file mode 100644 index 0000000..bc4ef16 --- /dev/null +++ b/prototype/RolesAssignments/Models/Student.cs @@ -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; } +} diff --git a/prototype/RolesAssignments/Models/StudentAssignment.cs b/prototype/RolesAssignments/Models/StudentAssignment.cs new file mode 100644 index 0000000..75b1f16 --- /dev/null +++ b/prototype/RolesAssignments/Models/StudentAssignment.cs @@ -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; } +} diff --git a/prototype/RolesAssignments/Models/StudentSummaryDto.cs b/prototype/RolesAssignments/Models/StudentSummaryDto.cs new file mode 100644 index 0000000..1caf3f3 --- /dev/null +++ b/prototype/RolesAssignments/Models/StudentSummaryDto.cs @@ -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; } +} diff --git a/prototype/RolesAssignments/Program.cs b/prototype/RolesAssignments/Program.cs new file mode 100644 index 0000000..6add0a6 --- /dev/null +++ b/prototype/RolesAssignments/Program.cs @@ -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(); +builder.Services.AddScoped(); + +// ---- 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(); +// builder.Services.AddScoped(); +// builder.Services.AddScoped(); +// builder.Services.AddScoped(); +// +// ...and nothing else in the entire codebase would change. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// ---- 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(); +}); + +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(); + +// 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(); diff --git a/prototype/RolesAssignments/Properties/launchSettings.json b/prototype/RolesAssignments/Properties/launchSettings.json new file mode 100644 index 0000000..52456a4 --- /dev/null +++ b/prototype/RolesAssignments/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/prototype/RolesAssignments/RolesAssignments.csproj b/prototype/RolesAssignments/RolesAssignments.csproj new file mode 100644 index 0000000..c9ba6f7 --- /dev/null +++ b/prototype/RolesAssignments/RolesAssignments.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/prototype/RolesAssignments/RolesAssignments.csproj.user b/prototype/RolesAssignments/RolesAssignments.csproj.user new file mode 100644 index 0000000..9ff5820 --- /dev/null +++ b/prototype/RolesAssignments/RolesAssignments.csproj.user @@ -0,0 +1,6 @@ + + + + https + + \ No newline at end of file diff --git a/prototype/RolesAssignments/RolesAssignments.http b/prototype/RolesAssignments/RolesAssignments.http new file mode 100644 index 0000000..38ef18b --- /dev/null +++ b/prototype/RolesAssignments/RolesAssignments.http @@ -0,0 +1,6 @@ +@RolesAssignments_HostAddress = http://localhost:5079 + +GET {{RolesAssignments_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/prototype/RolesAssignments/RolesAssignments.sln b/prototype/RolesAssignments/RolesAssignments.sln new file mode 100644 index 0000000..c2c2496 --- /dev/null +++ b/prototype/RolesAssignments/RolesAssignments.sln @@ -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 diff --git a/prototype/RolesAssignments/appsettings.Development.json b/prototype/RolesAssignments/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/prototype/RolesAssignments/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/prototype/RolesAssignments/appsettings.json b/prototype/RolesAssignments/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/prototype/RolesAssignments/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/prototype/RolesAssignments/readme.md b/prototype/RolesAssignments/readme.md new file mode 100644 index 0000000..8f3b86c --- /dev/null +++ b/prototype/RolesAssignments/readme.md @@ -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)