Prototype illustrative role assignments project

This commit is contained in:
ivan-pelly
2026-02-19 07:19:50 -08:00
parent 9d9a416d1c
commit f178add2be
34 changed files with 2118 additions and 0 deletions
@@ -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);
}
}