20 KiB
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
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_primaryflag 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.AuthorizeAsyncwith 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_assignmentstable 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.
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.
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.
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.
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
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:
- Extract
studentIdandgoalIdfrom the request. - Call
AuthorizeAsync(User, new StudentResource(studentId), Operations.EditGoal). - If authorization fails, return
403 Forbidden. - 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.
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.
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:
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
[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)
[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)
[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
ViewSensitiveRecordsoperation is checked viaAuthorizeAsyncbefore returning sensitive content. - List queries: Sensitive records are excluded from query results unless the current user meets the primary teacher criteria:
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.