This commit is contained in:
2026-02-27 16:56:41 -08:00
parent d52ba6e59c
commit 6de30bd77e
17 changed files with 508 additions and 50 deletions
+1
View File
@@ -53,6 +53,7 @@ builder.Services.AddAuthentication(options =>
builder.Services.AddAuthorization();
builder.Services.AddScoped<TokenService>();
builder.Services.AddScoped<PermissionService>();
builder.Services.AddHttpClient<TranscriptionService>(client =>
{
+81 -20
View File
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using WinStudentGoalTracker.Models;
using WinStudentGoalTracker.BaseClasses;
@@ -12,14 +13,11 @@ 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]
[HttpGet("my")]
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")]
[ProducesResponseType(typeof(ResponseResult<IEnumerable<StudentResponse>>), StatusCodes.Status200OK)]
public async Task<ActionResult<ResponseResult<IEnumerable<StudentResponse>>>> GetAll()
public async Task<ActionResult<ResponseResult<IEnumerable<StudentResponse>>>> GetMyStudents()
{
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
if (error is not null)
@@ -27,7 +25,34 @@ public class StudentController : BaseController
return error;
}
var students = await _studentRepository.GetAllAsync();
var students = await _studentRepository.GetMyStudentsAsync(userId, programId, role);
var response = students.Select(StudentResponse.FromDatabaseModel);
return Ok(new ResponseResult<IEnumerable<StudentResponse>>
{
Success = true,
Data = response
});
}
// TODO refactor with database changes to ensure
// users who are a district admin are actually associated with a district, and
// then this endpoint should validate that the requested program is part of the district
// Once that is in place, then district admins will be allowed to call this function.
[HttpGet("program/{idProgram:guid}")]
[Authorize(Roles = $"{UserRoles.SuperAdmin}")]
[ProducesResponseType(typeof(ResponseResult<IEnumerable<StudentResponse>>), StatusCodes.Status200OK)]
public async Task<ActionResult<ResponseResult<IEnumerable<StudentResponse>>>> GetStudentsForProgram(Guid idProgram)
{
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
if (error is not null)
{
return error;
}
var students = await _studentRepository.GetStudentsByProgramAsync(idProgram);
var response = students.Select(StudentResponse.FromDatabaseModel);
return Ok(new ResponseResult<IEnumerable<StudentResponse>>
@@ -38,12 +63,21 @@ public class StudentController : BaseController
}
[HttpGet("{idStudent:guid}")]
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")]
[ProducesResponseType(typeof(ResponseResult<StudentResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<StudentResponse>), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ResponseResult<StudentResponse>>> GetById(Guid idStudent)
{
var student = await _studentRepository.GetByIdAsync(idStudent);
if (student is null)
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
if (error is not null)
{
return error;
}
var students = await _studentRepository.GetMyStudentsAsync(userId, programId, role);
if (!students.Select(s => s.IdStudent).Contains(idStudent))
{
return NotFound(new ResponseResult<StudentResponse>
{
@@ -51,6 +85,8 @@ public class StudentController : BaseController
Message = "Student not found."
});
}
var student = students.Single(s => s.IdStudent == idStudent);
return Ok(new ResponseResult<StudentResponse>
{
@@ -60,21 +96,20 @@ public class StudentController : BaseController
}
[HttpPost]
[Authorize(Roles = $"{UserRoles.Teacher}")]
[ProducesResponseType(typeof(ResponseResult<StudentResponse>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ResponseResult<StudentResponse>), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ResponseResult<StudentResponse>>> Create([FromBody] CreateStudentDto request)
public async Task<ActionResult<ResponseResult<StudentResponse>>> Create([FromBody] CreateStudentDto newStudentData)
{
var existing = await _studentRepository.GetByIdAsync(request.IdStudent);
if (existing is not null)
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
if (error is not null)
{
return BadRequest(new ResponseResult<StudentResponse>
{
Success = false,
Message = $"Student with id {request.IdStudent} already exists."
});
return error;
}
var created = await _studentRepository.InsertAsync(request);
var newStudentId = Guid.NewGuid();
var created = await _studentRepository.InsertAsync(newStudentData, newStudentId, programId, userId);
if (created is null)
{
return BadRequest(new ResponseResult<StudentResponse>
@@ -93,12 +128,20 @@ public class StudentController : BaseController
}
[HttpPut("{idStudent:guid}")]
[Authorize(Roles = $"{UserRoles.Teacher}")]
[ProducesResponseType(typeof(ResponseResult<StudentResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<StudentResponse>), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ResponseResult<StudentResponse>>> Update(Guid idStudent, [FromBody] UpdateStudentDto request)
{
var existing = await _studentRepository.GetByIdAsync(idStudent);
if (existing is null)
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
if (error is not null)
{
return error;
}
var students = await _studentRepository.GetMyStudentsAsync(userId, programId, role);
if (!students.Select(s => s.IdStudent).Contains(idStudent))
{
return NotFound(new ResponseResult<StudentResponse>
{
@@ -127,10 +170,28 @@ public class StudentController : BaseController
}
[HttpDelete("{idStudent:guid}")]
[Authorize(Roles = $"{UserRoles.Teacher}")]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ResponseResult<object>>> Delete(Guid idStudent)
{
var (userId, email, programId, role, error) = GetProgramUserFromClaims();
if (error is not null)
{
return error;
}
var students = await _studentRepository.GetMyStudentsAsync(userId, programId, role);
if (!students.Select(s => s.IdStudent).Contains(idStudent))
{
return NotFound(new ResponseResult<StudentResponse>
{
Success = false,
Message = "Student not found."
});
}
var deleted = await _studentRepository.DeleteAsync(idStudent);
if (!deleted)
{
@@ -2,8 +2,6 @@ namespace WinStudentGoalTracker.DataAccess;
public class CreateStudentDto
{
public required Guid IdStudent { get; set; }
public Guid? IdProgram { get; set; }
public string? Identifier { get; set; }
public int? ProgramYear { get; set; }
public DateTime? EnrollmentDate { get; set; }
@@ -2,7 +2,6 @@ namespace WinStudentGoalTracker.DataAccess;
public class UpdateStudentDto
{
public Guid? IdProgram { get; set; }
public string? Identifier { get; set; }
public int? ProgramYear { get; set; }
public DateTime? EnrollmentDate { get; set; }
@@ -4,6 +4,7 @@ public class dbStudent
{
public required Guid IdStudent { get; set; }
public Guid? IdProgram { get; set; }
public Guid PrimaryTeacherId { get; set; }
public string? Identifier { get; set; }
public int? ProgramYear { get; set; }
public DateTime? EnrollmentDate { get; set; }
@@ -1,6 +1,7 @@
using System.Data;
using Dapper;
using MySql.Data.MySqlClient;
using WinStudentGoalTracker.Models;
namespace WinStudentGoalTracker.DataAccess;
@@ -8,11 +9,33 @@ public class StudentRepository
{
private IDbConnection Connection => new MySqlConnection(DatabaseManager.ConnectionString);
public async Task<IEnumerable<dbStudent>> GetAllAsync()
public async Task<IEnumerable<dbStudent>> GetMyStudentsAsync(Guid userId, Guid programId, string role)
{
return role switch
{
UserRoles.Teacher or UserRoles.ProgramAdmin =>
await GetStudentsByProgramAsync(programId),
UserRoles.Paraeducator =>
await GetAssignedStudentsAsync(userId, programId),
_ => Enumerable.Empty<dbStudent>()
};
}
public async Task<IEnumerable<dbStudent>> GetStudentsByProgramAsync(Guid programId)
{
using var db = Connection;
return await db.QueryAsync<dbStudent>(
"sp_Student_GetAll",
"sp_Student_GetByProgram",
new { p_id_program = programId.ToString() },
commandType: CommandType.StoredProcedure);
}
private async Task<IEnumerable<dbStudent>> GetAssignedStudentsAsync(Guid userId, Guid programId)
{
using var db = Connection;
return await db.QueryAsync<dbStudent>(
"sp_Student_GetByUserAndProgram",
new { p_id_user = userId.ToString(), p_id_program = programId.ToString() },
commandType: CommandType.StoredProcedure);
}
@@ -25,15 +48,16 @@ public class StudentRepository
commandType: CommandType.StoredProcedure);
}
public async Task<dbStudent?> InsertAsync(CreateStudentDto dto)
public async Task<dbStudent?> InsertAsync(CreateStudentDto dto, Guid newStudentGuid, Guid programId, Guid userId)
{
using var db = Connection;
return await db.QuerySingleOrDefaultAsync<dbStudent>(
"sp_Student_Insert",
new
{
p_id_student = dto.IdStudent.ToString(),
p_id_program = dto.IdProgram?.ToString(),
p_id_student = newStudentGuid.ToString(),
p_id_program = programId,
p_id_user = userId.ToString(),
p_identifier = dto.Identifier,
p_program_year = dto.ProgramYear,
p_enrollment_date = dto.EnrollmentDate,
@@ -50,7 +74,6 @@ public class StudentRepository
new
{
p_id_student = idStudent.ToString(),
p_id_program = dto.IdProgram?.ToString(),
p_identifier = dto.Identifier,
p_program_year = dto.ProgramYear,
p_enrollment_date = dto.EnrollmentDate,
+17
View File
@@ -0,0 +1,17 @@
namespace WinStudentGoalTracker.Models;
public static class EntityType
{
public const string SchoolDistrict = "school_district";
public const string Program = "program";
public const string User = "user";
public const string Student = "student";
public const string Goal = "goal";
public const string ProgressEvent = "progress_event";
public static string? TryParse(string value) =>
All.Contains(value) ? value : null;
public static readonly IReadOnlyList<string> All =
[SchoolDistrict, Program, User, Student, Goal, ProgressEvent];
}
@@ -0,0 +1,15 @@
namespace WinStudentGoalTracker.Models;
public static class PermissionAction
{
public const string Create = "create";
public const string Read = "read";
public const string Update = "update";
public const string Delete = "delete";
public static string? TryParse(string value) =>
All.Contains(value) ? value : null;
public static readonly IReadOnlyList<string> All =
[Create, Read, Update, Delete];
}
+273
View File
@@ -0,0 +1,273 @@
using WinStudentGoalTracker.Models;
namespace WinStudentGoalTracker.Services;
public readonly record struct PermissionRule(bool Mine, bool Others);
public static class PermissionMatrix
{
private static readonly PermissionRule Allow = new(true, true);
private static readonly PermissionRule MineOnly = new(true, false);
private static readonly PermissionRule Deny = new(false, false);
// _rules[role][entity][action] → PermissionRule
private static readonly Dictionary<string, Dictionary<string, Dictionary<string, PermissionRule>>> _rules = new()
{
// ──────────────────────────────────────────────
// Super Admin — full access to everything
// ──────────────────────────────────────────────
[UserRoles.SuperAdmin] = new()
{
[EntityType.SchoolDistrict] = new()
{
[PermissionAction.Create] = Allow,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = Allow,
[PermissionAction.Delete] = Allow,
},
[EntityType.Program] = new()
{
[PermissionAction.Create] = Allow,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = Allow,
[PermissionAction.Delete] = Allow,
},
[EntityType.User] = new()
{
[PermissionAction.Create] = Allow,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = Allow,
[PermissionAction.Delete] = Allow,
},
[EntityType.Student] = new()
{
[PermissionAction.Create] = Allow,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = Allow,
[PermissionAction.Delete] = Allow,
},
[EntityType.Goal] = new()
{
[PermissionAction.Create] = Allow,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = Allow,
[PermissionAction.Delete] = Allow,
},
[EntityType.ProgressEvent] = new()
{
[PermissionAction.Create] = Allow,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = Allow,
[PermissionAction.Delete] = Allow,
},
},
// ──────────────────────────────────────────────
// District Admin
// ──────────────────────────────────────────────
[UserRoles.DistrictAdmin] = new()
{
[EntityType.SchoolDistrict] = new()
{
[PermissionAction.Create] = Deny,
[PermissionAction.Read] = MineOnly,
[PermissionAction.Update] = MineOnly,
[PermissionAction.Delete] = Deny,
},
[EntityType.Program] = new()
{
[PermissionAction.Create] = MineOnly,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = Allow,
[PermissionAction.Delete] = MineOnly,
},
[EntityType.User] = new()
{
[PermissionAction.Create] = Allow,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = Allow,
[PermissionAction.Delete] = Allow,
},
[EntityType.Student] = new()
{
[PermissionAction.Create] = Allow,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = Allow,
[PermissionAction.Delete] = Allow,
},
[EntityType.Goal] = new()
{
[PermissionAction.Create] = Allow,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = Allow,
[PermissionAction.Delete] = Allow,
},
[EntityType.ProgressEvent] = new()
{
[PermissionAction.Create] = Allow,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = Allow,
[PermissionAction.Delete] = Allow,
},
},
// ──────────────────────────────────────────────
// Program Admin
// ──────────────────────────────────────────────
[UserRoles.ProgramAdmin] = new()
{
[EntityType.SchoolDistrict] = new()
{
[PermissionAction.Create] = Deny,
[PermissionAction.Read] = Deny,
[PermissionAction.Update] = Deny,
[PermissionAction.Delete] = Deny,
},
[EntityType.Program] = new()
{
[PermissionAction.Create] = Deny,
[PermissionAction.Read] = MineOnly,
[PermissionAction.Update] = MineOnly,
[PermissionAction.Delete] = Deny,
},
[EntityType.User] = new()
{
[PermissionAction.Create] = MineOnly,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = MineOnly,
[PermissionAction.Delete] = Deny,
},
[EntityType.Student] = new()
{
[PermissionAction.Create] = Allow,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = Allow,
[PermissionAction.Delete] = MineOnly,
},
[EntityType.Goal] = new()
{
[PermissionAction.Create] = Allow,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = Allow,
[PermissionAction.Delete] = MineOnly,
},
[EntityType.ProgressEvent] = new()
{
[PermissionAction.Create] = Allow,
[PermissionAction.Read] = Allow,
[PermissionAction.Update] = Allow,
[PermissionAction.Delete] = MineOnly,
},
},
// ──────────────────────────────────────────────
// Teacher
// ──────────────────────────────────────────────
[UserRoles.Teacher] = new()
{
[EntityType.SchoolDistrict] = new()
{
[PermissionAction.Create] = Deny,
[PermissionAction.Read] = Deny,
[PermissionAction.Update] = Deny,
[PermissionAction.Delete] = Deny,
},
[EntityType.Program] = new()
{
[PermissionAction.Create] = Deny,
[PermissionAction.Read] = MineOnly,
[PermissionAction.Update] = Deny,
[PermissionAction.Delete] = Deny,
},
[EntityType.User] = new()
{
[PermissionAction.Create] = Deny,
[PermissionAction.Read] = MineOnly,
[PermissionAction.Update] = MineOnly,
[PermissionAction.Delete] = Deny,
},
[EntityType.Student] = new()
{
[PermissionAction.Create] = MineOnly,
[PermissionAction.Read] = MineOnly,
[PermissionAction.Update] = MineOnly,
[PermissionAction.Delete] = MineOnly,
},
[EntityType.Goal] = new()
{
[PermissionAction.Create] = MineOnly,
[PermissionAction.Read] = MineOnly,
[PermissionAction.Update] = MineOnly,
[PermissionAction.Delete] = MineOnly,
},
[EntityType.ProgressEvent] = new()
{
[PermissionAction.Create] = MineOnly,
[PermissionAction.Read] = MineOnly,
[PermissionAction.Update] = MineOnly,
[PermissionAction.Delete] = MineOnly,
},
},
// ──────────────────────────────────────────────
// Paraeducator
// ──────────────────────────────────────────────
[UserRoles.Paraeducator] = new()
{
[EntityType.SchoolDistrict] = new()
{
[PermissionAction.Create] = Deny,
[PermissionAction.Read] = Deny,
[PermissionAction.Update] = Deny,
[PermissionAction.Delete] = Deny,
},
[EntityType.Program] = new()
{
[PermissionAction.Create] = Deny,
[PermissionAction.Read] = MineOnly,
[PermissionAction.Update] = Deny,
[PermissionAction.Delete] = Deny,
},
[EntityType.User] = new()
{
[PermissionAction.Create] = Deny,
[PermissionAction.Read] = MineOnly,
[PermissionAction.Update] = MineOnly,
[PermissionAction.Delete] = Deny,
},
[EntityType.Student] = new()
{
[PermissionAction.Create] = Deny,
[PermissionAction.Read] = MineOnly,
[PermissionAction.Update] = Deny,
[PermissionAction.Delete] = Deny,
},
[EntityType.Goal] = new()
{
[PermissionAction.Create] = Deny,
[PermissionAction.Read] = MineOnly,
[PermissionAction.Update] = Deny,
[PermissionAction.Delete] = Deny,
},
[EntityType.ProgressEvent] = new()
{
[PermissionAction.Create] = MineOnly,
[PermissionAction.Read] = MineOnly,
[PermissionAction.Update] = MineOnly,
[PermissionAction.Delete] = Deny,
},
},
};
public static PermissionRule? GetRule(string role, string entity, string action)
{
if (_rules.TryGetValue(role, out var entities)
&& entities.TryGetValue(entity, out var actions)
&& actions.TryGetValue(action, out var rule))
{
return rule;
}
return null;
}
}
+36
View File
@@ -0,0 +1,36 @@
using WinStudentGoalTracker.Models;
namespace WinStudentGoalTracker.Services;
public class PermissionService
{
/// <summary>
/// Checks whether the given role is allowed to perform the specified action
/// on the specified entity, considering ownership.
/// </summary>
/// <param name="role">The user's role (use UserRoles constants)</param>
/// <param name="entity">The entity being acted on (use EntityType constants)</param>
/// <param name="action">The action being performed (use PermissionAction constants)</param>
/// <param name="isMine">Whether the resource belongs to the requesting user.
/// For Create actions this parameter is ignored.</param>
/// <returns>True if the action is permitted, false otherwise.</returns>
public bool IsAllowed(string role, string entity, string action, bool isMine = true)
{
var rule = PermissionMatrix.GetRule(role, entity, action);
if (rule is null)
{
return false;
}
// Create has no ownership concept — use the Mine field as a general "can create" flag
if (action == PermissionAction.Create)
{
return rule.Value.Mine;
}
return isMine ? rule.Value.Mine : rule.Value.Others;
}
}
@@ -0,0 +1,18 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetByProgram`(
IN p_id_program CHAR(36)
)
BEGIN
SELECT
id_student,
id_program,
identifier,
program_year,
enrollment_date,
expected_grad,
created_at
FROM student
WHERE id_program = p_id_program
ORDER BY id_student;
END;;
DELIMITER ;
@@ -0,0 +1,21 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetByUserAndProgram`(
IN p_id_user CHAR(36),
IN p_id_program CHAR(36)
)
BEGIN
SELECT
s.id_student,
s.id_program,
s.identifier,
s.program_year,
s.enrollment_date,
s.expected_grad,
s.created_at
FROM student s
JOIN user_student us ON us.id_student = s.id_student
WHERE us.id_user = p_id_user
AND s.id_program = p_id_program
ORDER BY s.id_student;
END;;
DELIMITER ;
@@ -28,6 +28,22 @@ BEGIN
p_expected_grad,
UTC_TIMESTAMP()
);
INSERT INTO user_student
(
id_user_student,
id_user,
id_student,
is_primary
)
VALUES
(
UUID(),
p_id_user,
p_id_student,
1
);
SELECT
id_student,
id_program,
@@ -1,7 +1,6 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_Update`(
IN p_id_student CHAR(36),
IN p_id_program CHAR(36),
IN p_identifier VARCHAR(50),
IN p_program_year INT,
IN p_enrollment_date DATE,
@@ -10,7 +9,6 @@ CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_Update`(
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),
-9
View File
@@ -1,9 +0,0 @@
CREATE TABLE `permission` (
`id_permission` char(36) NOT NULL,
`name` varchar(100) DEFAULT NULL,
`description` text,
`resource` varchar(100) DEFAULT NULL,
`action` varchar(50) DEFAULT NULL,
`scope` varchar(50) DEFAULT NULL,
PRIMARY KEY (`id_permission`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-10
View File
@@ -1,10 +0,0 @@
CREATE TABLE `role_permission` (
`id_role_permission` char(36) NOT NULL,
`id_role` char(36) DEFAULT NULL,
`id_permission` char(36) DEFAULT NULL,
PRIMARY KEY (`id_role_permission`),
KEY `role_permission_ibfk_1` (`id_role`),
KEY `role_permission_ibfk_2` (`id_permission`),
CONSTRAINT `role_permission_ibfk_1` FOREIGN KEY (`id_role`) REFERENCES `role` (`id_role`),
CONSTRAINT `role_permission_ibfk_2` FOREIGN KEY (`id_permission`) REFERENCES `permission` (`id_permission`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;