mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 09:57:37 +00:00
Prototype illustrative role assignments project
This commit is contained in:
+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