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