mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 01:47:41 +00:00
added first controller and corresponding stored procedures.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
namespace WinStudentGoalTracker.Api.Configuration;
|
||||
|
||||
public static class ConfigHelper
|
||||
{
|
||||
public static IConfiguration Configuration { get; set; } = null!;
|
||||
}
|
||||
+18
-29
@@ -1,41 +1,30 @@
|
||||
using WinStudentGoalTracker.Api.Configuration;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||
builder.Services.AddOpenApi();
|
||||
ConfigHelper.Configuration = builder.Configuration;
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseCors();
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
var summaries = new[]
|
||||
{
|
||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
||||
};
|
||||
|
||||
app.MapGet("/weatherforecast", () =>
|
||||
{
|
||||
var forecast = Enumerable.Range(1, 5).Select(index =>
|
||||
new WeatherForecast
|
||||
(
|
||||
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
||||
Random.Shared.Next(-20, 55),
|
||||
summaries[Random.Shared.Next(summaries.Length)]
|
||||
))
|
||||
.ToArray();
|
||||
return forecast;
|
||||
})
|
||||
.WithName("GetWeatherForecast");
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
|
||||
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
|
||||
{
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
}
|
||||
|
||||
+3
-1
@@ -7,7 +7,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6" />
|
||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||
<PackageReference Include="MySql.Data" Version="8.4.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+35
-1
@@ -1,6 +1,40 @@
|
||||
@api_HostAddress = http://localhost:5123
|
||||
|
||||
GET {{api_HostAddress}}/weatherforecast/
|
||||
GET {{api_HostAddress}}/api/student
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
GET {{api_HostAddress}}/api/student/1001
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
POST {{api_HostAddress}}/api/student
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"idStudent": 1001,
|
||||
"idProgram": 10,
|
||||
"identifier": "WIN-1001",
|
||||
"programYear": 2026,
|
||||
"enrollmentDate": "2026-01-15",
|
||||
"expectedGrad": "2028-06-01"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
PUT {{api_HostAddress}}/api/student/1001
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"identifier": "WIN-1001-A",
|
||||
"programYear": 2027
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
DELETE {{api_HostAddress}}/api/student/1001
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=localhost;Port=3306;Database=win_student_goal_tracker;Uid=root;Pwd=change_me;"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=localhost;Port=3306;Database=win_student_goal_tracker;Uid=root;Pwd=change_me;"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
# Role-Based Access Control Design Document
|
||||
|
||||
## Student Goal & Progress Tracking Application
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document defines the authorization model for the Student Goal & Progress Tracking application. The system uses a combination of role-based access control (RBAC) and resource-level assignment enforcement to determine what each user can do and to whom.
|
||||
|
||||
All authorization decisions flow from two sources of truth:
|
||||
|
||||
- **User role** — defines the category of actions a user may perform (e.g., Teacher, Paraeducator, Supervisor).
|
||||
- **Student assignments** — defines which students a user has access to and whether they hold primary responsibility.
|
||||
|
||||
These two dimensions are evaluated together at every access point. A user's role determines what operations are available to them in general, and their assignment to a specific student determines whether they can perform those operations against that student's records.
|
||||
|
||||
---
|
||||
|
||||
## 2. Roles
|
||||
|
||||
### 2.1 Teacher
|
||||
|
||||
Teachers are the primary instructional staff responsible for student documentation.
|
||||
|
||||
- May be assigned to multiple students.
|
||||
- May hold primary assignment for one or more students.
|
||||
- Primary assignment grants full control over the student's goals and records.
|
||||
- Non-primary assignment grants read access and the ability to add progress entries.
|
||||
|
||||
### 2.2 Paraeducator
|
||||
|
||||
Paraeducators are support staff who assist students in the field.
|
||||
|
||||
- May be assigned to multiple students.
|
||||
- May add progress entries and critical notes for assigned students.
|
||||
- May edit or delete only entries they personally created.
|
||||
- Cannot create, edit, or archive goals.
|
||||
- Cannot view sensitive records unless explicitly permitted.
|
||||
|
||||
### 2.3 Supervisor
|
||||
|
||||
Supervisors are oversight users who review documentation for evaluation, audit, or legal purposes.
|
||||
|
||||
- Have read-only access to all student records, entries, and reports.
|
||||
- Cannot create, modify, or delete any records.
|
||||
- Access is modeled through student assignments, ensuring uniform query behavior.
|
||||
|
||||
---
|
||||
|
||||
## 3. Student Assignments
|
||||
|
||||
### 3.1 Design Principle
|
||||
|
||||
The `student_assignments` table is the single source of truth for access control. Every user — regardless of role — must have an active assignment to a student in order to access that student's records. This eliminates role-based branching in queries and ensures that all access is explicit and auditable.
|
||||
|
||||
### 3.2 Assignment Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE student_assignments (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL,
|
||||
student_id INT NOT NULL,
|
||||
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (student_id) REFERENCES students(id)
|
||||
);
|
||||
```
|
||||
|
||||
### 3.3 Field Definitions
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `user_id` | The user being granted access. |
|
||||
| `student_id` | The student the user is being assigned to. |
|
||||
| `is_primary` | Whether this user holds primary responsibility for this student. Primary users may create and manage goals, edit the student profile, and view sensitive records. |
|
||||
| `start_date` | The date the assignment becomes effective. |
|
||||
| `end_date` | The date the assignment expires. NULL indicates an open-ended assignment. |
|
||||
| `is_active` | Whether the assignment is currently active. Supports manual deactivation independent of date range. |
|
||||
| `created_at` | Timestamp of assignment creation. |
|
||||
| `created_by` | The user who created the assignment. |
|
||||
|
||||
### 3.4 Primary Assignment Rules
|
||||
|
||||
- Only users with the Teacher role may hold a primary assignment (`is_primary = TRUE`).
|
||||
- A student should have exactly one primary assignment at any given time.
|
||||
- Paraeducators and Supervisors always have `is_primary = FALSE`.
|
||||
- The `is_primary` flag determines access to privileged operations such as goal management and student profile editing.
|
||||
|
||||
### 3.5 Supervisor Assignment Strategy
|
||||
|
||||
When a supervisor is added to the system, they receive an assignment record for each student in the program. When a new student is created, an assignment record is created for each active supervisor. This ensures supervisors are queryable through the same assignment-based access path as all other users, eliminating role-based branching in data access queries.
|
||||
|
||||
---
|
||||
|
||||
## 4. Permission Matrix
|
||||
|
||||
The following matrix defines which operations are available to each role and assignment level. All operations require an active assignment to the relevant student.
|
||||
|
||||
| Operation | Primary Teacher | Non-Primary Teacher | Paraeducator | Supervisor |
|
||||
|---|---|---|---|---|
|
||||
| View student profile | ✅ | ✅ | ✅ | ✅ |
|
||||
| Edit student profile | ✅ | ❌ | ❌ | ❌ |
|
||||
| Create goal | ✅ | ❌ | ❌ | ❌ |
|
||||
| Edit goal | ✅ | ❌ | ❌ | ❌ |
|
||||
| Archive goal | ✅ | ❌ | ❌ | ❌ |
|
||||
| Add progress entry | ✅ | ✅ | ✅ | ❌ |
|
||||
| Edit own progress entry | ✅ | ✅ | ✅ | ❌ |
|
||||
| Edit others' progress entry | ✅ | ❌ | ❌ | ❌ |
|
||||
| Delete own progress entry | ✅ | ✅ | ✅ | ❌ |
|
||||
| Delete others' progress entry | ✅ | ❌ | ❌ | ❌ |
|
||||
| Add critical note | ✅ | ✅ | ✅ | ❌ |
|
||||
| View sensitive records | ✅ | ❌ | ❌ | ❌ |
|
||||
| Generate report | ✅ | ✅ | ❌ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 5. Authorization Architecture
|
||||
|
||||
### 5.1 Approach
|
||||
|
||||
The application uses ASP.NET Core's built-in policy-based and resource-based authorization framework. Authorization logic is centralized in handler classes rather than distributed across controllers or repositories.
|
||||
|
||||
There are two complementary authorization strategies:
|
||||
|
||||
- **Resource-based authorization** — used for single-entity endpoints. The controller loads or identifies the resource, then calls `IAuthorizationService.AuthorizeAsync` with a resource object. Registered handlers evaluate the user's role and assignment.
|
||||
- **Query-scoped access** — used for list endpoints. The query itself joins through the `student_assignments` table so that unauthorized records never leave the database.
|
||||
|
||||
### 5.2 Operations
|
||||
|
||||
Operations represent the vocabulary of actions the system can authorize. They are defined as static fields on a central `Operations` class.
|
||||
|
||||
```csharp
|
||||
public static class Operations
|
||||
{
|
||||
public static readonly OperationAuthorizationRequirement ViewStudent =
|
||||
new() { Name = nameof(ViewStudent) };
|
||||
public static readonly OperationAuthorizationRequirement EditStudent =
|
||||
new() { Name = nameof(EditStudent) };
|
||||
public static readonly OperationAuthorizationRequirement CreateGoal =
|
||||
new() { Name = nameof(CreateGoal) };
|
||||
public static readonly OperationAuthorizationRequirement EditGoal =
|
||||
new() { Name = nameof(EditGoal) };
|
||||
public static readonly OperationAuthorizationRequirement ArchiveGoal =
|
||||
new() { Name = nameof(ArchiveGoal) };
|
||||
public static readonly OperationAuthorizationRequirement AddProgressEntry =
|
||||
new() { Name = nameof(AddProgressEntry) };
|
||||
public static readonly OperationAuthorizationRequirement EditProgressEntry =
|
||||
new() { Name = nameof(EditProgressEntry) };
|
||||
public static readonly OperationAuthorizationRequirement DeleteProgressEntry =
|
||||
new() { Name = nameof(DeleteProgressEntry) };
|
||||
public static readonly OperationAuthorizationRequirement AddCriticalNote =
|
||||
new() { Name = nameof(AddCriticalNote) };
|
||||
public static readonly OperationAuthorizationRequirement ViewSensitiveRecords =
|
||||
new() { Name = nameof(ViewSensitiveRecords) };
|
||||
public static readonly OperationAuthorizationRequirement GenerateReport =
|
||||
new() { Name = nameof(GenerateReport) };
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Resource Objects
|
||||
|
||||
Resource objects are lightweight records that carry the context an authorization handler needs to make a decision. They are passed to `AuthorizeAsync` and routed to the appropriate handler by the framework's type-matching system.
|
||||
|
||||
```csharp
|
||||
public record StudentResource(int StudentId);
|
||||
|
||||
public record ProgressEntryResource(int StudentId, int EntryId, int CreatedByUserId);
|
||||
|
||||
public record CriticalNoteResource(int StudentId, int NoteId, int CreatedByUserId, string? SensitivityLevel);
|
||||
```
|
||||
|
||||
### 5.4 Authorization Handlers
|
||||
|
||||
#### 5.4.1 Student Authorization Handler
|
||||
|
||||
This handler evaluates all student-scoped operations. It loads the user's assignment and checks the `is_primary` flag and user role to determine access.
|
||||
|
||||
```csharp
|
||||
public class StudentAuthorizationHandler
|
||||
: AuthorizationHandler<OperationAuthorizationRequirement, StudentResource>
|
||||
{
|
||||
private readonly IAssignmentRepository _assignments;
|
||||
|
||||
public StudentAuthorizationHandler(IAssignmentRepository assignments)
|
||||
{
|
||||
_assignments = assignments;
|
||||
}
|
||||
|
||||
protected override async Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
OperationAuthorizationRequirement requirement,
|
||||
StudentResource resource)
|
||||
{
|
||||
var userId = context.User.GetUserId();
|
||||
var role = context.User.GetRole();
|
||||
|
||||
var assignment = await _assignments.GetActiveAssignment(userId, resource.StudentId);
|
||||
if (assignment is null)
|
||||
return;
|
||||
|
||||
switch (requirement.Name)
|
||||
{
|
||||
case nameof(Operations.ViewStudent):
|
||||
// Any active assignment grants view access
|
||||
context.Succeed(requirement);
|
||||
break;
|
||||
|
||||
case nameof(Operations.EditStudent):
|
||||
case nameof(Operations.CreateGoal):
|
||||
case nameof(Operations.EditGoal):
|
||||
case nameof(Operations.ArchiveGoal):
|
||||
case nameof(Operations.ViewSensitiveRecords):
|
||||
// Only the primary teacher
|
||||
if (assignment.IsPrimary && role == Role.Teacher)
|
||||
context.Succeed(requirement);
|
||||
break;
|
||||
|
||||
case nameof(Operations.AddProgressEntry):
|
||||
case nameof(Operations.AddCriticalNote):
|
||||
// Teachers and paraeducators with any assignment
|
||||
if (role is Role.Teacher or Role.Paraeducator)
|
||||
context.Succeed(requirement);
|
||||
break;
|
||||
|
||||
case nameof(Operations.GenerateReport):
|
||||
// Teachers and supervisors
|
||||
if (role is Role.Teacher or Role.Supervisor)
|
||||
context.Succeed(requirement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.4.2 Progress Entry Authorization Handler
|
||||
|
||||
This handler evaluates entry-level ownership for edit and delete operations. It is called after the student-level check has already passed in the controller.
|
||||
|
||||
```csharp
|
||||
public class ProgressEntryAuthorizationHandler
|
||||
: AuthorizationHandler<OperationAuthorizationRequirement, ProgressEntryResource>
|
||||
{
|
||||
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();
|
||||
var role = context.User.GetRole();
|
||||
|
||||
switch (requirement.Name)
|
||||
{
|
||||
case nameof(Operations.EditProgressEntry):
|
||||
case nameof(Operations.DeleteProgressEntry):
|
||||
// Any author can edit or delete their own entry
|
||||
if (resource.CreatedByUserId == userId)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
}
|
||||
|
||||
// Primary teachers can edit or delete anyone's entry
|
||||
// for their assigned students
|
||||
if (role == Role.Teacher)
|
||||
{
|
||||
var assignment = await _assignments
|
||||
.GetActiveAssignment(userId, resource.StudentId);
|
||||
if (assignment is not null && assignment.IsPrimary)
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 Handler Registration
|
||||
|
||||
```csharp
|
||||
builder.Services.AddAuthorizationCore();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, StudentAuthorizationHandler>();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, ProgressEntryAuthorizationHandler>();
|
||||
builder.Services.AddScoped<IAssignmentRepository, CachedAssignmentRepository>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Data Access Patterns
|
||||
|
||||
### 6.1 Single-Resource Endpoints
|
||||
|
||||
For endpoints that operate on a specific student, goal, or entry, the controller performs authorization before executing the operation.
|
||||
|
||||
```
|
||||
Request → Extract resource identifier → AuthorizeAsync → Proceed or return 403
|
||||
```
|
||||
|
||||
Example flow for updating a goal:
|
||||
|
||||
1. Extract `studentId` and `goalId` from the request.
|
||||
2. Call `AuthorizeAsync(User, new StudentResource(studentId), Operations.EditGoal)`.
|
||||
3. If authorization fails, return `403 Forbidden`.
|
||||
4. Load the goal, validate it belongs to the student, and perform the update.
|
||||
|
||||
### 6.2 List Endpoints
|
||||
|
||||
For endpoints that return collections (e.g., "get my students"), the query joins through `student_assignments` to scope results to the current user. This ensures unauthorized records never leave the database.
|
||||
|
||||
```csharp
|
||||
public async Task<IEnumerable<StudentSummaryDto>> GetAccessibleStudents(int userId)
|
||||
{
|
||||
return await _db.QueryAsync<StudentSummaryDto>(
|
||||
@"SELECT s.id, s.identifier, s.program_year, s.age,
|
||||
sa.is_primary
|
||||
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 });
|
||||
}
|
||||
```
|
||||
|
||||
This query is role-agnostic. Supervisors, teachers, and paraeducators all use the same query. The assignments table determines what each user sees.
|
||||
|
||||
### 6.3 Assignment Caching
|
||||
|
||||
Because the assignment lookup is called on every single-resource authorization check, the repository is wrapped with a per-request cache to avoid redundant database queries within a single HTTP request.
|
||||
|
||||
```csharp
|
||||
public class CachedAssignmentRepository : IAssignmentRepository
|
||||
{
|
||||
private readonly IAssignmentRepository _inner;
|
||||
private readonly Dictionary<(int, int), StudentAssignment?> _cache = new();
|
||||
|
||||
public CachedAssignmentRepository(IAssignmentRepository inner)
|
||||
{
|
||||
_inner = inner;
|
||||
}
|
||||
|
||||
public async Task<StudentAssignment?> GetActiveAssignment(int userId, int studentId)
|
||||
{
|
||||
var key = (userId, studentId);
|
||||
if (_cache.TryGetValue(key, out var cached))
|
||||
return cached;
|
||||
|
||||
var result = await _inner.GetActiveAssignment(userId, studentId);
|
||||
_cache[key] = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is registered as a scoped service so the cache lives for the duration of one HTTP request.
|
||||
|
||||
---
|
||||
|
||||
## 7. Core Assignment Query
|
||||
|
||||
The core query used by both the authorization handlers and the cached repository:
|
||||
|
||||
```sql
|
||||
SELECT id, user_id, student_id, is_primary, start_date, end_date, is_active
|
||||
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;
|
||||
```
|
||||
|
||||
This query enforces both the active flag and the date range, supporting time-bound assignments such as temporary coverage.
|
||||
|
||||
---
|
||||
|
||||
## 8. Controller Patterns
|
||||
|
||||
### 8.1 Single-Resource Authorization
|
||||
|
||||
```csharp
|
||||
[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());
|
||||
return NoContent();
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 Two-Layer Authorization (Student + Entry Ownership)
|
||||
|
||||
```csharp
|
||||
[HttpPut("{entryId}")]
|
||||
public async Task<IActionResult> UpdateEntry(int studentId, int entryId, [FromBody] UpdateEntryRequest request)
|
||||
{
|
||||
// Layer 1: Can you access this student at all?
|
||||
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: Can you edit THIS entry specifically?
|
||||
var entryAuth = await _auth.AuthorizeAsync(
|
||||
User,
|
||||
new ProgressEntryResource(entry.StudentId, entry.Id, entry.CreatedByUserId),
|
||||
Operations.EditProgressEntry);
|
||||
if (!entryAuth.Succeeded) return Forbid();
|
||||
|
||||
await _entries.Update(entryId, request, User.GetUserId());
|
||||
return NoContent();
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 List Endpoint (Query-Scoped)
|
||||
|
||||
```csharp
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetMyStudents()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var students = await _students.GetAccessibleStudents(userId);
|
||||
return Ok(students);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Sensitive Record Visibility
|
||||
|
||||
Records flagged as sensitive are only visible to users whose assignment has `is_primary = TRUE` and whose role is `Teacher`. This is enforced in two places:
|
||||
|
||||
- **Single-resource access**: The `ViewSensitiveRecords` operation is checked via `AuthorizeAsync` before returning sensitive content.
|
||||
- **List queries**: Sensitive records are excluded from query results unless the current user meets the primary teacher criteria:
|
||||
|
||||
```sql
|
||||
AND (pe.is_sensitive = FALSE OR sa.is_primary = TRUE)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Key Design Decisions
|
||||
|
||||
### 10.1 Assignments as the Single Source of Truth
|
||||
|
||||
All access decisions — whether evaluated in authorization handlers or embedded in SQL queries — derive from the `student_assignments` table. Roles determine the nature of permitted operations. Assignments determine the scope. This separation keeps the system predictable and auditable.
|
||||
|
||||
### 10.2 `is_primary` Over Assignment Type Enum
|
||||
|
||||
Rather than an enum of assignment types, the `is_primary` boolean provides a clear binary distinction: primary users have full control over a student's goals and records; non-primary users have limited, contributory access. The user's role combined with the `is_primary` flag covers all permission combinations described in the permission matrix.
|
||||
|
||||
### 10.3 Supervisors Modeled as Assignments
|
||||
|
||||
Supervisors receive explicit assignment records for each student. This avoids special-casing supervisor access in queries and handlers. Supervisors always have `is_primary = FALSE`, which naturally restricts them to read-only operations.
|
||||
|
||||
### 10.4 Two Authorization Strategies
|
||||
|
||||
Single-resource endpoints use `IAuthorizationService.AuthorizeAsync` with resource objects. List endpoints use query-scoped access via the assignments table. Both strategies use the same underlying assignment data, ensuring consistency.
|
||||
|
||||
---
|
||||
|
||||
## 11. Testing Strategy
|
||||
|
||||
### 11.1 Unit Tests
|
||||
|
||||
Each authorization handler should be unit tested in isolation by mocking the assignment repository and asserting `Succeed` or implicit denial for every combination of role, assignment status, `is_primary` flag, and operation.
|
||||
|
||||
### 11.2 Integration Tests
|
||||
|
||||
List queries should be integration tested to verify that they return only records the user is assigned to. A useful pattern is to compare the results of a list query against individual `AuthorizeAsync` calls for each returned record, asserting that they agree.
|
||||
|
||||
### 11.3 Consistency Audit
|
||||
|
||||
A periodic or on-demand audit job can iterate over list query results and verify that every returned record passes the corresponding `AuthorizeAsync` check. This catches drift between the two authorization strategies.
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace WinStudentGoalTracker.BaseClasses;
|
||||
|
||||
public class BaseController : ControllerBase
|
||||
{
|
||||
protected (int userId, ActionResult? error) GetUserIdFromClaims()
|
||||
{
|
||||
var userIdClaim = User.FindFirst("user_id")?.Value
|
||||
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userIdClaim) || !int.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
return (0, Unauthorized("Missing or invalid user_id claim."));
|
||||
}
|
||||
|
||||
return (userId, null);
|
||||
}
|
||||
|
||||
protected (string email, List<string> roles, ActionResult? error) GetUserDetailsFromClaims()
|
||||
{
|
||||
var email = User.FindFirst(ClaimTypes.Email)?.Value;
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
return (string.Empty, new List<string>(), Unauthorized("Missing email claim."));
|
||||
}
|
||||
|
||||
var roles = User.FindAll(ClaimTypes.Role).Select(claim => claim.Value).ToList();
|
||||
return (email, roles, null);
|
||||
}
|
||||
|
||||
protected bool HasRole(string role)
|
||||
{
|
||||
return User.IsInRole(role);
|
||||
}
|
||||
|
||||
protected bool HasAnyRole(params string[] roles)
|
||||
{
|
||||
return roles.Any(User.IsInRole);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using WinStudentGoalTracker.Models;
|
||||
using WinStudentGoalTracker.BaseClasses;
|
||||
using WinStudentGoalTracker.DataAccess;
|
||||
|
||||
namespace WinStudentGoalTracker.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class StudentController : BaseController
|
||||
{
|
||||
private readonly StudentRepository _studentRepository = new();
|
||||
|
||||
|
||||
// TODO refactor this stored procedure
|
||||
// to getmystudents
|
||||
// This required auth system to be set up first
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ResponseResult<IEnumerable<StudentResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ResponseResult<IEnumerable<StudentResponse>>>> GetAll()
|
||||
{
|
||||
var students = await _studentRepository.GetAllAsync();
|
||||
var response = students.Select(StudentResponse.FromDatabaseModel);
|
||||
|
||||
return Ok(new ResponseResult<IEnumerable<StudentResponse>>
|
||||
{
|
||||
Success = true,
|
||||
Data = response
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("{idStudent:int}")]
|
||||
[ProducesResponseType(typeof(ResponseResult<StudentResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ResponseResult<StudentResponse>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ResponseResult<StudentResponse>>> GetById(int idStudent)
|
||||
{
|
||||
var student = await _studentRepository.GetByIdAsync(idStudent);
|
||||
if (student is null)
|
||||
{
|
||||
return NotFound(new ResponseResult<StudentResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Student not found."
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new ResponseResult<StudentResponse>
|
||||
{
|
||||
Success = true,
|
||||
Data = StudentResponse.FromDatabaseModel(student)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ResponseResult<StudentResponse>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ResponseResult<StudentResponse>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<ResponseResult<StudentResponse>>> Create([FromBody] CreateStudentDto request)
|
||||
{
|
||||
var existing = await _studentRepository.GetByIdAsync(request.IdStudent);
|
||||
if (existing is not null)
|
||||
{
|
||||
return BadRequest(new ResponseResult<StudentResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Student with id {request.IdStudent} already exists."
|
||||
});
|
||||
}
|
||||
|
||||
var created = await _studentRepository.InsertAsync(request);
|
||||
if (created is null)
|
||||
{
|
||||
return BadRequest(new ResponseResult<StudentResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Unable to create student."
|
||||
});
|
||||
}
|
||||
|
||||
var response = StudentResponse.FromDatabaseModel(created);
|
||||
return CreatedAtAction(nameof(GetById), new { idStudent = response.IdStudent }, new ResponseResult<StudentResponse>
|
||||
{
|
||||
Success = true,
|
||||
Data = response
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("{idStudent:int}")]
|
||||
[ProducesResponseType(typeof(ResponseResult<StudentResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ResponseResult<StudentResponse>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ResponseResult<StudentResponse>>> Update(int idStudent, [FromBody] UpdateStudentDto request)
|
||||
{
|
||||
var existing = await _studentRepository.GetByIdAsync(idStudent);
|
||||
if (existing is null)
|
||||
{
|
||||
return NotFound(new ResponseResult<StudentResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Student not found."
|
||||
});
|
||||
}
|
||||
|
||||
var updated = await _studentRepository.UpdateAsync(idStudent, request);
|
||||
var refreshed = await _studentRepository.GetByIdAsync(idStudent);
|
||||
if (refreshed is null)
|
||||
{
|
||||
return NotFound(new ResponseResult<StudentResponse>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Student not found after update."
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new ResponseResult<StudentResponse>
|
||||
{
|
||||
Success = true,
|
||||
Message = updated ? null : "No changes were applied.",
|
||||
Data = StudentResponse.FromDatabaseModel(refreshed)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("{idStudent:int}")]
|
||||
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ResponseResult<object>>> Delete(int idStudent)
|
||||
{
|
||||
var deleted = await _studentRepository.DeleteAsync(idStudent);
|
||||
if (!deleted)
|
||||
{
|
||||
return NotFound(new ResponseResult<object>
|
||||
{
|
||||
Success = false,
|
||||
Message = "Student not found."
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new ResponseResult<object>
|
||||
{
|
||||
Success = true,
|
||||
Message = "Student deleted."
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Dapper;
|
||||
using WinStudentGoalTracker.Api.Configuration;
|
||||
|
||||
namespace WinStudentGoalTracker.DataAccess;
|
||||
|
||||
public static class DatabaseManager
|
||||
{
|
||||
static DatabaseManager()
|
||||
{
|
||||
DefaultTypeMap.MatchNamesWithUnderscores = true;
|
||||
}
|
||||
|
||||
public static string ConnectionString =>
|
||||
ConfigHelper.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? throw new MissingFieldException("DefaultConnection not configured.");
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace WinStudentGoalTracker.DataAccess;
|
||||
|
||||
public class CreateStudentDto
|
||||
{
|
||||
public required int IdStudent { get; set; }
|
||||
public int? IdProgram { get; set; }
|
||||
public string? Identifier { get; set; }
|
||||
public int? ProgramYear { get; set; }
|
||||
public DateTime? EnrollmentDate { get; set; }
|
||||
public DateTime? ExpectedGrad { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace WinStudentGoalTracker.DataAccess;
|
||||
|
||||
public class UpdateStudentDto
|
||||
{
|
||||
public int? IdProgram { get; set; }
|
||||
public string? Identifier { get; set; }
|
||||
public int? ProgramYear { get; set; }
|
||||
public DateTime? EnrollmentDate { get; set; }
|
||||
public DateTime? ExpectedGrad { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace WinStudentGoalTracker.DataAccess;
|
||||
|
||||
public class dbStudent
|
||||
{
|
||||
public required int IdStudent { get; set; }
|
||||
public int? IdProgram { get; set; }
|
||||
public string? Identifier { get; set; }
|
||||
public int? ProgramYear { get; set; }
|
||||
public DateTime? EnrollmentDate { get; set; }
|
||||
public DateTime? ExpectedGrad { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using MySql.Data.MySqlClient;
|
||||
|
||||
namespace WinStudentGoalTracker.DataAccess;
|
||||
|
||||
public class StudentRepository
|
||||
{
|
||||
private IDbConnection Connection => new MySqlConnection(DatabaseManager.ConnectionString);
|
||||
|
||||
public async Task<IEnumerable<dbStudent>> GetAllAsync()
|
||||
{
|
||||
using var db = Connection;
|
||||
return await db.QueryAsync<dbStudent>(
|
||||
"sp_Student_GetAll",
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<dbStudent?> GetByIdAsync(int idStudent)
|
||||
{
|
||||
using var db = Connection;
|
||||
return await db.QuerySingleOrDefaultAsync<dbStudent>(
|
||||
"sp_Student_GetById",
|
||||
new { p_id_student = idStudent },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<dbStudent?> InsertAsync(CreateStudentDto dto)
|
||||
{
|
||||
using var db = Connection;
|
||||
return await db.QuerySingleOrDefaultAsync<dbStudent>(
|
||||
"sp_Student_Insert",
|
||||
new
|
||||
{
|
||||
p_id_student = dto.IdStudent,
|
||||
p_id_program = dto.IdProgram,
|
||||
p_identifier = dto.Identifier,
|
||||
p_program_year = dto.ProgramYear,
|
||||
p_enrollment_date = dto.EnrollmentDate,
|
||||
p_expected_grad = dto.ExpectedGrad
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(int idStudent, UpdateStudentDto dto)
|
||||
{
|
||||
using var db = Connection;
|
||||
var rowsAffected = await db.ExecuteScalarAsync<int>(
|
||||
"sp_Student_Update",
|
||||
new
|
||||
{
|
||||
p_id_student = idStudent,
|
||||
p_id_program = dto.IdProgram,
|
||||
p_identifier = dto.Identifier,
|
||||
p_program_year = dto.ProgramYear,
|
||||
p_enrollment_date = dto.EnrollmentDate,
|
||||
p_expected_grad = dto.ExpectedGrad
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
return rowsAffected > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int idStudent)
|
||||
{
|
||||
using var db = Connection;
|
||||
var rowsAffected = await db.ExecuteScalarAsync<int>(
|
||||
"sp_Student_Delete",
|
||||
new { p_id_student = idStudent },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
return rowsAffected > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace WinStudentGoalTracker.Models;
|
||||
|
||||
public class ResponseResult<T>
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public T? Data { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using WinStudentGoalTracker.DataAccess;
|
||||
|
||||
namespace WinStudentGoalTracker.Models;
|
||||
|
||||
public class StudentResponse
|
||||
{
|
||||
public int IdStudent { get; set; }
|
||||
public int? IdProgram { get; set; }
|
||||
public string? Identifier { get; set; }
|
||||
public int? ProgramYear { get; set; }
|
||||
public DateTime? EnrollmentDate { get; set; }
|
||||
public DateTime? ExpectedGrad { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
|
||||
public static StudentResponse FromDatabaseModel(dbStudent student)
|
||||
{
|
||||
return new StudentResponse
|
||||
{
|
||||
IdStudent = student.IdStudent,
|
||||
IdProgram = student.IdProgram,
|
||||
Identifier = student.Identifier,
|
||||
ProgramYear = student.ProgramYear,
|
||||
EnrollmentDate = student.EnrollmentDate,
|
||||
ExpectedGrad = student.ExpectedGrad,
|
||||
CreatedAt = student.CreatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace WinStudentGoalTracker.Models;
|
||||
|
||||
public static class UserRoles
|
||||
{
|
||||
// Role names from role-based-access-control.md
|
||||
public const string Teacher = "Teacher";
|
||||
public const string Paraeducator = "Paraeducator";
|
||||
public const string ProgramAdmin = "ProgramAdmin";
|
||||
public const string DistrictAdmin = "DistrictAdmin";
|
||||
public const string SuperAdmin = "SuperAdmin";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[]
|
||||
{
|
||||
Teacher,
|
||||
Paraeducator,
|
||||
ProgramAdmin,
|
||||
DistrictAdmin,
|
||||
SuperAdmin
|
||||
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
DROP PROCEDURE IF EXISTS sp_Student_Delete;
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_Student_Delete(IN p_id_student INT)
|
||||
BEGIN
|
||||
DELETE FROM student
|
||||
WHERE id_student = p_id_student;
|
||||
|
||||
SELECT ROW_COUNT() AS rows_affected;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
@@ -0,0 +1,18 @@
|
||||
DROP PROCEDURE IF EXISTS sp_Student_GetAll;
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_Student_GetAll()
|
||||
BEGIN
|
||||
SELECT
|
||||
id_student,
|
||||
id_program,
|
||||
identifier,
|
||||
program_year,
|
||||
enrollment_date,
|
||||
expected_grad,
|
||||
created_at
|
||||
FROM student
|
||||
ORDER BY id_student;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
@@ -0,0 +1,19 @@
|
||||
DROP PROCEDURE IF EXISTS sp_Student_GetById;
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_Student_GetById(IN p_id_student INT)
|
||||
BEGIN
|
||||
SELECT
|
||||
id_student,
|
||||
id_program,
|
||||
identifier,
|
||||
program_year,
|
||||
enrollment_date,
|
||||
expected_grad,
|
||||
created_at
|
||||
FROM student
|
||||
WHERE id_student = p_id_student
|
||||
LIMIT 1;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
@@ -0,0 +1,47 @@
|
||||
DROP PROCEDURE IF EXISTS sp_Student_Insert;
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_Student_Insert(
|
||||
IN p_id_student INT,
|
||||
IN p_id_program INT,
|
||||
IN p_identifier VARCHAR(50),
|
||||
IN p_program_year INT,
|
||||
IN p_enrollment_date DATE,
|
||||
IN p_expected_grad DATE
|
||||
)
|
||||
BEGIN
|
||||
INSERT INTO student
|
||||
(
|
||||
id_student,
|
||||
id_program,
|
||||
identifier,
|
||||
program_year,
|
||||
enrollment_date,
|
||||
expected_grad,
|
||||
created_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
p_id_student,
|
||||
p_id_program,
|
||||
p_identifier,
|
||||
p_program_year,
|
||||
p_enrollment_date,
|
||||
p_expected_grad,
|
||||
UTC_TIMESTAMP()
|
||||
);
|
||||
|
||||
SELECT
|
||||
id_student,
|
||||
id_program,
|
||||
identifier,
|
||||
program_year,
|
||||
enrollment_date,
|
||||
expected_grad,
|
||||
created_at
|
||||
FROM student
|
||||
WHERE id_student = p_id_student
|
||||
LIMIT 1;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
@@ -0,0 +1,25 @@
|
||||
DROP PROCEDURE IF EXISTS sp_Student_Update;
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_Student_Update(
|
||||
IN p_id_student INT,
|
||||
IN p_id_program INT,
|
||||
IN p_identifier VARCHAR(50),
|
||||
IN p_program_year INT,
|
||||
IN p_enrollment_date DATE,
|
||||
IN p_expected_grad DATE
|
||||
)
|
||||
BEGIN
|
||||
UPDATE student
|
||||
SET
|
||||
id_program = COALESCE(p_id_program, id_program),
|
||||
identifier = COALESCE(p_identifier, identifier),
|
||||
program_year = COALESCE(p_program_year, program_year),
|
||||
enrollment_date = COALESCE(p_enrollment_date, enrollment_date),
|
||||
expected_grad = COALESCE(p_expected_grad, expected_grad)
|
||||
WHERE id_student = p_id_student;
|
||||
|
||||
SELECT ROW_COUNT() AS rows_affected;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
Reference in New Issue
Block a user