Skip to content

Commit

Permalink
Add Additional Students Methods (#60)
Browse files Browse the repository at this point in the history
* Extend `UsersService`

Added the `GetGroupsOfStudentAsync` and `GetSemestersOfStudentAsync` methods

* Add authorization handler for enrollments

* Register view enrollments policy

* Update users controller tests

* Introduce `IsTeacherOrAdministrator` policy

* Update users endpoints tests

* Change policies for users endpoints

- Allowed all users to access GET method `api/users/teachers`.
- Allowed access to GET method `api/users/students`  for teachers.

* Fix tests

* Implement students enrollments endpoints
  • Loading branch information
romandykyi authored Dec 19, 2023
1 parent 82da81b commit 63db8ad
Show file tree
Hide file tree
Showing 10 changed files with 584 additions and 11 deletions.
2 changes: 2 additions & 0 deletions Core/Policy/Policies.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ public static class Policies
public const string Default = "Default";
public const string IsStudent = "IsStudent";
public const string IsTeacher = "IsTeacher";
public const string IsTeacherOrAdministrator = "IsTeacherOrAdmin";
public const string HasAdministratorPermission = "HasAdministratorPermission";
public const string CanViewStudentEnrollments = "CanViewStudentEnrollments";
}
24 changes: 23 additions & 1 deletion Core/Services/Users/IUsersService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using EUniversity.Core.Dtos.Users;
using EUniversity.Core.Dtos.University;
using EUniversity.Core.Dtos.Users;
using EUniversity.Core.Filters;
using EUniversity.Core.Models;
using EUniversity.Core.Models.University;
using EUniversity.Core.Pagination;

namespace EUniversity.Core.Services.Users;
Expand Down Expand Up @@ -31,4 +33,24 @@ Task<Page<UserViewDto>> GetUsersInRoleAsync(string role, PaginationProperties? p
/// Page with all users.
/// </returns>
Task<Page<UserViewDto>> GetAllUsersAsync(PaginationProperties? properties = null, IFilter<ApplicationUser>? filter = null);

/// <summary>
/// Gets a page with groups of the student.
/// </summary>
/// <param name="studentId">An ID of the student.</param>
/// <param name="filter">Optional filter for groups.</param>
/// <returns>
/// A page with groups of the student with ID <paramref name="studentId"/>.
/// </returns>
Task<Page<GroupPreviewDto>> GetGroupsOfStudentAsync(string studentId, PaginationProperties properties, IFilter<Group>? filter = null);

/// <summary>
/// Gets a page with semesters of the student.
/// </summary>
/// <param name="studentId">An ID of the student.</param>
/// <param name="filter">Optional filter for semesters.</param>
/// <returns>
/// A page with semesters of the student with ID <paramref name="studentId"/>.
/// </returns>
Task<Page<SemesterPreviewDto>> GetSemestersOfStudentAsync(string studentId, PaginationProperties properties, IFilter<Semester>? filter = null);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using EUniversity.Auth;
using EUniversity.Controllers;
using EUniversity.Core.Policy;
using IdentityModel;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Routing;
using System.Security.Claims;

namespace EUniversity.Tests.Auth;

public class ViewStudentEnrollmentsAuthorizationHandlerTests
{
private readonly string TestUserId = Guid.NewGuid().ToString();
private readonly string TestRouteStudentId = Guid.NewGuid().ToString();

private ClaimsPrincipal GetUser(string id, params string[] roles)
{
List<Claim> claims =new()
{
new(JwtClaimTypes.Subject, id)
};
foreach (string role in roles)
{
claims.Add(new(JwtClaimTypes.Role, role));
}
ClaimsIdentity identity = new(claims, "Test");
return new ClaimsPrincipal(identity);
}

private AuthorizationHandlerContext GetHandlerContext(ClaimsPrincipal user)
{
RouteValueDictionary routeValues = new()
{
{ UsersController.StudentIdRouteKey, TestRouteStudentId }
};
HttpContext httpContext = Substitute.For<HttpContext>();
var routeValuesFeature = Substitute.For<IRouteValuesFeature>();
routeValuesFeature.RouteValues.Returns(routeValues);

httpContext.Features.Get<IRouteValuesFeature>().Returns(routeValuesFeature);

IAuthorizationRequirement[] requirements =
{
new ViewStudentEnrollmentsAuthorizationRequirement()
};

return new(requirements, user, httpContext);
}

[Test]
[TestCase(Roles.Administrator)]
[TestCase(Roles.Teacher)]
public async Task AdministratorOrTeacher_Succeeds(string role)
{
// Arrange
ClaimsPrincipal user = GetUser(TestUserId, role);
AuthorizationHandlerContext context = GetHandlerContext(user);
ViewStudentEnrollmentsAuthorizationHandler handler = new();

// Act
await handler.HandleAsync(context);

// Assert
Assert.That(context.HasSucceeded);
}

[Test]
public async Task UserAccessesOwnEnrollments_Succeeds()
{
// Arrange
ClaimsPrincipal user = GetUser(TestRouteStudentId);
AuthorizationHandlerContext context = GetHandlerContext(user);
ViewStudentEnrollmentsAuthorizationHandler handler = new();

// Act
await handler.HandleAsync(context);

// Assert
Assert.That(context.HasSucceeded);
}

[Test]
public async Task UserAccessesEnrollmentsOfAnotherUser_Fails()
{
// Arrange
ClaimsPrincipal user = GetUser(TestUserId);
AuthorizationHandlerContext context = GetHandlerContext(user);
ViewStudentEnrollmentsAuthorizationHandler handler = new();

// Act
await handler.HandleAsync(context);

// Assert
Assert.That(context.HasFailed);
}
}
50 changes: 50 additions & 0 deletions EUniversity/Auth/ViewStudentEnrollmentsAuthorizationHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using Duende.IdentityServer.Extensions;
using EUniversity.Controllers;
using EUniversity.Core.Policy;
using IdentityModel;
using Microsoft.AspNetCore.Authorization;

namespace EUniversity.Auth;

/// <summary>
/// Authorization handler that determines whether user can view students enrollments.
/// Allows teachers and administrators to view all enrollments and other users to view
/// only their own enrollments.
/// </summary>
public class ViewStudentEnrollmentsAuthorizationHandler :
AuthorizationHandler<ViewStudentEnrollmentsAuthorizationRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ViewStudentEnrollmentsAuthorizationRequirement requirement)
{
if (!context.User.IsAuthenticated())
{
context.Fail();
return Task.CompletedTask;
}
// If user is either a teacher or an administrator,
// he/she has an access to all enrollments
if (context.User.HasClaim(JwtClaimTypes.Role, Roles.Teacher) ||
context.User.HasClaim(JwtClaimTypes.Role, Roles.Administrator))
{
context.Succeed(requirement);
return Task.CompletedTask;
}

// Get an ID from route values
if (context.Resource is not HttpContext httpContext)
{
throw new InvalidOperationException("Cannot access HTTP context");
}
object? routeId = httpContext.GetRouteValue(UsersController.StudentIdRouteKey) ??
throw new InvalidOperationException($"Cannot get route value of '{UsersController.StudentIdRouteKey}'");
// Each user can view his/her enrollments
if (context.User.GetSubjectId() == routeId.ToString())
{
context.Succeed(requirement);
return Task.CompletedTask;
}

context.Fail();
return Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using Microsoft.AspNetCore.Authorization;

namespace EUniversity.Auth;

public class ViewStudentEnrollmentsAuthorizationRequirement : IAuthorizationRequirement
{
}
126 changes: 122 additions & 4 deletions EUniversity/Controllers/UsersController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using EUniversity.Core.Dtos.Users;
using EUniversity.Core.Dtos.University;
using EUniversity.Core.Dtos.Users;
using EUniversity.Core.Pagination;
using EUniversity.Core.Policy;
using EUniversity.Core.Services.Auth;
Expand All @@ -13,12 +14,14 @@ namespace EUniversity.Controllers;
[ApiController]
[Route("api/users")]
[FluentValidationAutoValidation]
[Authorize(Policies.HasAdministratorPermission)]
[Authorize]
public class UsersController : ControllerBase
{
private readonly IAuthService _authService;
private readonly IUsersService _usersService;

public const string StudentIdRouteKey = "studentId";

public UsersController(IAuthService authService, IUsersService usersService)
{
_authService = authService;
Expand Down Expand Up @@ -48,6 +51,7 @@ public UsersController(IAuthService authService, IUsersService usersService)
[ProducesResponseType(typeof(Page<UserViewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[Authorize(Policies.HasAdministratorPermission)]
public async Task<IActionResult> GetAllUsersAsync(
[FromQuery] PaginationProperties paginationProperties,
[FromQuery] UsersFilterProperties usersFilter)
Expand All @@ -73,12 +77,13 @@ public async Task<IActionResult> GetAllUsersAsync(
/// </remarks>
/// <response code="200">Returns a page with students</response>
/// <response code="401">Unauthorized user call</response>
/// <response code="403">User lacks 'Administrator' role</response>
/// <response code="403">User lacks 'Administrator' or 'Teacher' role</response>
[HttpGet]
[Route("students")]
[ProducesResponseType(typeof(Page<UserViewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[Authorize(Policies.IsTeacherOrAdministrator)]
public async Task<IActionResult> GetAllStudentsAsync(
[FromQuery] PaginationProperties paginationProperties,
[FromQuery] UsersFilterProperties usersFilter)
Expand All @@ -104,7 +109,6 @@ public async Task<IActionResult> GetAllStudentsAsync(
/// </remarks>
/// <response code="200">Returns a page with teachers</response>
/// <response code="401">Unauthorized user call</response>
/// <response code="403">User lacks 'Administrator' role</response>
[HttpGet]
[Route("teachers")]
[ProducesResponseType(typeof(Page<UserViewDto>), StatusCodes.Status200OK)]
Expand Down Expand Up @@ -156,6 +160,7 @@ private async Task<IActionResult> RegisterAsync(RegisterUsersDto students, strin
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[Authorize(Policies.HasAdministratorPermission)]
public async Task<IActionResult> RegisterStudentsAsync([FromBody] RegisterUsersDto students)
{
return await RegisterAsync(students, Roles.Student, "api/users/students");
Expand All @@ -179,9 +184,122 @@ public async Task<IActionResult> RegisterStudentsAsync([FromBody] RegisterUsersD
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[Authorize(Policies.HasAdministratorPermission)]
public async Task<IActionResult> RegisterTeachersAsync([FromBody] RegisterUsersDto teachers)
{
return await RegisterAsync(teachers, Roles.Teacher, "api/users/teachers");
}
#endregion

#region Enrollments
/// <summary>
/// Gets a page with groups of the student.
/// </summary>
/// <remarks>
/// <para>
/// If a user has the 'Administrator' or 'Teacher' role then he/she can use this method
/// for every user, otherwise a user can view only his/her own groups.
/// </para>
/// <para>
/// The 'studentId' route value is not checked in this method, if student with
/// this ID does not exist, then empty page will be returned.
/// </para>
/// <para>
/// If there is no items in the requested page, then empty page will be returned.
/// </para>
/// <para>
/// If the query param 'semesterId' is 0, then groups that are not linked to any semesters will be returned.</para>
/// </remarks>
/// <param name="studentId">ID of the student whose groups will be returned.</param>
/// <param name="properties">Pagination properties.</param>
/// <param name="filterProperties">Filter properties.</param>
/// <param name="name">An optional name to filter groups by.</param>
/// <param name="sortingMode">
/// An optional sorting mode.
/// <para>
/// Possible values:
/// </para>
/// <ul>
/// <li>default(or 0) - no sorting will be applied;</li>
/// <li>name(or 1) - groups will be sorted by their name(from a to z), this mode is applied by default;</li>
/// <li>nameDescending(or 2) - groups will be sorted by their name in descending order(from z to a);</li>
/// <li>newest(or 3) - groups will be sorted by their creation date in descending order;</li>
/// <li>oldest(or 4) - groups will be sorted by their creation date in ascending order.</li>
/// </ul>
/// </param>
/// <response code="200">Returns requested page with groups.</response>
/// <response code="400">Bad request.</response>
/// <response code="401">Unauthorized user call.</response>
/// <response code="403">User don't have a permission to view specified student's enrollments.</response>
[HttpGet("students/{studentId}/groups")]
[Authorize(Policies.CanViewStudentEnrollments)]
[ProducesResponseType(typeof(Page<GroupPreviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetStudentGroupsAsync(
[FromRoute] string studentId,
[FromQuery] PaginationProperties properties,
[FromQuery] GroupsFilterProperties filterProperties,
[FromQuery] string? name,
[FromQuery] DefaultFilterSortingMode sortingMode = DefaultFilterSortingMode.Name)
{
GroupsFilter filter = new(filterProperties, name ?? string.Empty, sortingMode);
return Ok(await _usersService.GetGroupsOfStudentAsync(studentId, properties, filter));
}

/// <summary>
/// Gets a page with semesters of the student.
/// </summary>
/// <remarks>
/// <para>
/// If a user has the 'Administrator' or 'Teacher' role then he/she can use this method
/// for every user, otherwise a user can view only his/her own semesters.
/// </para>
/// <para>
/// The 'studentId' route value is not checked in this method, if student with
/// this ID does not exist, then empty page will be returned.
/// </para>
/// <para>
/// If there is no items in the requested page, then empty page will be returned.
/// </para>
/// <para>
/// If the query param 'semesterId' is 0, then semesters that are not linked to any semesters will be returned.</para>
/// </remarks>
/// <param name="studentId">ID of the student whose semesters will be returned.</param>
/// <param name="properties">Pagination properties.</param>
/// <param name="filterProperties">Filter properties.</param>
/// <param name="name">An optional name to filter semesters by.</param>
/// <param name="sortingMode">
/// An optional sorting mode.
/// <para>
/// Possible values:
/// </para>
/// <ul>
/// <li>default(or 0) - no sorting will be applied;</li>
/// <li>name(or 1) - semesters will be sorted by their name(from a to z), this mode is applied by default;</li>
/// <li>nameDescending(or 2) - semesters will be sorted by their name in descending order(from z to a);</li>
/// <li>newest(or 3) - semesters will be sorted by their creation date in descending order;</li>
/// <li>oldest(or 4) - semesters will be sorted by their creation date in ascending order.</li>
/// </ul>
/// </param>
/// <response code="200">Returns requested page with semesters.</response>
/// <response code="400">Bad request.</response>
/// <response code="401">Unauthorized user call.</response>
/// <response code="403">User don't have a permission to view specified student's enrollments.</response>
[HttpGet("students/{studentId}/semesters")]
[Authorize(Policies.CanViewStudentEnrollments)]
[ProducesResponseType(typeof(Page<SemesterPreviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetStudentSemestersAsync(
[FromRoute] string studentId,
[FromQuery] PaginationProperties properties,
[FromQuery] SemestersFilterProperties filterProperties,
[FromQuery] string? name,
[FromQuery] DefaultFilterSortingMode sortingMode = DefaultFilterSortingMode.Name)
{
SemestersFilter filter = new(filterProperties, name ?? string.Empty, sortingMode);
return Ok(await _usersService.GetSemestersOfStudentAsync(studentId, properties, filter));
}
#endregion
}
Loading

0 comments on commit 63db8ad

Please sign in to comment.