From e030ff5e46ccce43ad3da7e4cdbd8afa3964501e Mon Sep 17 00:00:00 2001 From: Vichea Nath Date: Thu, 24 Oct 2024 22:40:23 -0500 Subject: [PATCH] Add Permission Authorization & Assign Role --- .../Endpoints/ProjectsEndpoints.cs | 4 +- src/SearchBugs.Api/Endpoints/UserEndpoints.cs | 11 +++++ src/SearchBugs.Api/Program.cs | 2 +- src/SearchBugs.Api/SearchBugs.Api.csproj | 4 ++ src/SearchBugs.Api/SearchBugs.Api.http | 1 - .../Projects/ProjectPermission.cs | 11 +++++ .../Users/AssignRole/AssignRoleCommand.cs | 5 +++ .../AssignRole/AssignRoleCommandHandler.cs | 44 +++++++++++++++++++ .../AssignRole/AssignRoleCommandValidator.cs | 13 ++++++ .../Roles/IRoleRepository.cs | 10 +++++ src/SearchBugs.Domain/Roles/Role.cs | 3 +- src/SearchBugs.Domain/Users/UserRole.cs | 1 + .../Authentication/HasPermissionAttribute.cs | 10 +++++ .../Authentication/IPermissionService.cs | 6 +++ .../PermissionAuthorizationHandler.cs | 42 ++++++++++++++++++ .../PermissionAuthorizationPolicyProvider.cs | 27 ++++++++++++ .../Authentication/PermissionRequirement.cs | 14 ++++++ .../Authentication/PermissionService.cs | 28 ++++++++++++ .../DependencyInjection.cs | 8 +++- .../SearchBugs.Infrastructure.csproj | 1 + .../DependencyInjection.cs | 2 + .../Repositories/Repository.cs | 2 +- .../Repositories/RoleRepository.cs | 34 ++++++++++++++ 23 files changed, 277 insertions(+), 6 deletions(-) delete mode 100644 src/SearchBugs.Api/SearchBugs.Api.http create mode 100644 src/SearchBugs.Application/Projects/ProjectPermission.cs create mode 100644 src/SearchBugs.Application/Users/AssignRole/AssignRoleCommand.cs create mode 100644 src/SearchBugs.Application/Users/AssignRole/AssignRoleCommandHandler.cs create mode 100644 src/SearchBugs.Application/Users/AssignRole/AssignRoleCommandValidator.cs create mode 100644 src/SearchBugs.Domain/Roles/IRoleRepository.cs create mode 100644 src/SearchBugs.Infrastructure/Authentication/HasPermissionAttribute.cs create mode 100644 src/SearchBugs.Infrastructure/Authentication/IPermissionService.cs create mode 100644 src/SearchBugs.Infrastructure/Authentication/PermissionAuthorizationHandler.cs create mode 100644 src/SearchBugs.Infrastructure/Authentication/PermissionAuthorizationPolicyProvider.cs create mode 100644 src/SearchBugs.Infrastructure/Authentication/PermissionRequirement.cs create mode 100644 src/SearchBugs.Infrastructure/Authentication/PermissionService.cs create mode 100644 src/SearchBugs.Persistence/Repositories/RoleRepository.cs diff --git a/src/SearchBugs.Api/Endpoints/ProjectsEndpoints.cs b/src/SearchBugs.Api/Endpoints/ProjectsEndpoints.cs index d5999a5..ad67ef2 100644 --- a/src/SearchBugs.Api/Endpoints/ProjectsEndpoints.cs +++ b/src/SearchBugs.Api/Endpoints/ProjectsEndpoints.cs @@ -1,7 +1,9 @@ using MediatR; using Microsoft.AspNetCore.Mvc; +using SearchBugs.Application.Projects; using SearchBugs.Application.Projects.CreateProject; using SearchBugs.Application.Projects.GetProjects; +using SearchBugs.Infrastructure.Authentication; namespace SearchBugs.Api.Endpoints; @@ -14,7 +16,7 @@ public record CreateProjectRequest( public static void MapProjectsEndpoints(this IEndpointRouteBuilder app) { var projects = app.MapGroup("api/projects"); - projects.MapPost("", CreateProject).WithName(nameof(CreateProject)); + projects.MapPost("", CreateProject).WithName(nameof(CreateProject)).WithMetadata(new HasPermissionAttribute(ProjectPermission.Create)); projects.MapGet("", GetProjects).WithName(nameof(GetProjects)); } diff --git a/src/SearchBugs.Api/Endpoints/UserEndpoints.cs b/src/SearchBugs.Api/Endpoints/UserEndpoints.cs index e40a7d6..d2b8b8a 100644 --- a/src/SearchBugs.Api/Endpoints/UserEndpoints.cs +++ b/src/SearchBugs.Api/Endpoints/UserEndpoints.cs @@ -9,6 +9,7 @@ public static class UserEndpoints { public record UpdateUserRequest(string FirstName, string LastName); + public record AssignRoleRequest(Guid UserId, string Role); public static void MapUserEndpoints(this IEndpointRouteBuilder app) { @@ -16,6 +17,16 @@ public static void MapUserEndpoints(this IEndpointRouteBuilder app) users.MapGet("", GetUsers).WithName(nameof(GetUsers)); users.MapGet("{id}", GetUserDetail).WithName(nameof(GetUserDetail)); users.MapPut("{id}", UpdateUser).WithName(nameof(UpdateUser)); + users.MapPost("{id}/assign-role", AssignRole).WithName(nameof(AssignRole)); + } + + public static async Task AssignRole( + AssignRoleRequest request, + ISender sender) + { + var command = new AssignRoleCommand(request.UserId, request.Role); + var result = await sender.Send(command); + return Results.Ok(result); } public static async Task UpdateUser( diff --git a/src/SearchBugs.Api/Program.cs b/src/SearchBugs.Api/Program.cs index 412e184..ab3d584 100644 --- a/src/SearchBugs.Api/Program.cs +++ b/src/SearchBugs.Api/Program.cs @@ -35,7 +35,7 @@ private static void Main(string[] args) // app.UseHttpsRedirection(); app.UseAuthentication(); - + app.UseStaticFiles(); app.UseMiddleware(); app.Run(); } diff --git a/src/SearchBugs.Api/SearchBugs.Api.csproj b/src/SearchBugs.Api/SearchBugs.Api.csproj index 1aa423a..b594b64 100644 --- a/src/SearchBugs.Api/SearchBugs.Api.csproj +++ b/src/SearchBugs.Api/SearchBugs.Api.csproj @@ -31,4 +31,8 @@ + + + + diff --git a/src/SearchBugs.Api/SearchBugs.Api.http b/src/SearchBugs.Api/SearchBugs.Api.http deleted file mode 100644 index 5f28270..0000000 --- a/src/SearchBugs.Api/SearchBugs.Api.http +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/SearchBugs.Application/Projects/ProjectPermission.cs b/src/SearchBugs.Application/Projects/ProjectPermission.cs new file mode 100644 index 0000000..f97a6eb --- /dev/null +++ b/src/SearchBugs.Application/Projects/ProjectPermission.cs @@ -0,0 +1,11 @@ +namespace SearchBugs.Application.Projects; + +public static class ProjectPermission +{ + public const string Create = "Projects.Create"; + public const string Read = "Projects.Read"; + public const string Update = "Projects.Update"; + public const string Delete = "Projects.Delete"; + public const string Assign = "Projects.Assign"; + public const string Manage = "Projects.Manage"; +} diff --git a/src/SearchBugs.Application/Users/AssignRole/AssignRoleCommand.cs b/src/SearchBugs.Application/Users/AssignRole/AssignRoleCommand.cs new file mode 100644 index 0000000..e49cffa --- /dev/null +++ b/src/SearchBugs.Application/Users/AssignRole/AssignRoleCommand.cs @@ -0,0 +1,5 @@ +using Shared.Messaging; + +namespace SearchBugs.Application.Users.AssignRole; + +public record AssignRoleCommand(Guid UserId, string Role) : ICommand; diff --git a/src/SearchBugs.Application/Users/AssignRole/AssignRoleCommandHandler.cs b/src/SearchBugs.Application/Users/AssignRole/AssignRoleCommandHandler.cs new file mode 100644 index 0000000..9e9ee2d --- /dev/null +++ b/src/SearchBugs.Application/Users/AssignRole/AssignRoleCommandHandler.cs @@ -0,0 +1,44 @@ +using SearchBugs.Domain; +using SearchBugs.Domain.Roles; +using SearchBugs.Domain.Users; +using Shared.Messaging; +using Shared.Results; + +namespace SearchBugs.Application.Users.AssignRole; + +internal sealed class AssignRoleCommandHandler : ICommandHandler +{ + + private readonly IUserRepository _userRepository; + private readonly IRoleRepository _roleRepository; + private readonly IUnitOfWork _unitOfWork; + + public AssignRoleCommandHandler(IUserRepository userRepository, IUnitOfWork unitOfWork, IRoleRepository roleRepository) + { + _userRepository = userRepository; + _unitOfWork = unitOfWork; + _roleRepository = roleRepository; + } + + + public async Task Handle(AssignRoleCommand request, CancellationToken cancellationToken) + { + + var user = await _userRepository.GetByIdAsync(new UserId(request.UserId), cancellationToken); + if (user.IsFailure) + { + return Result.Failure(user.Error); + } + var role = await _roleRepository.GetByNameAsync(request.Role, cancellationToken); + + if (role.IsFailure) + { + return Result.Failure(role.Error); + } + + user.Value.AddRole(role.Value); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/SearchBugs.Application/Users/AssignRole/AssignRoleCommandValidator.cs b/src/SearchBugs.Application/Users/AssignRole/AssignRoleCommandValidator.cs new file mode 100644 index 0000000..bca4793 --- /dev/null +++ b/src/SearchBugs.Application/Users/AssignRole/AssignRoleCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace SearchBugs.Application.Users.AssignRole; + +internal class AssignRoleCommandValidator : AbstractValidator +{ + public AssignRoleCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty() + .Must(x => x != Guid.Empty); + RuleFor(x => x.Role).NotEmpty(); + } +} diff --git a/src/SearchBugs.Domain/Roles/IRoleRepository.cs b/src/SearchBugs.Domain/Roles/IRoleRepository.cs new file mode 100644 index 0000000..63258cc --- /dev/null +++ b/src/SearchBugs.Domain/Roles/IRoleRepository.cs @@ -0,0 +1,10 @@ +using Shared.Results; + +namespace SearchBugs.Domain.Roles; + +public interface IRoleRepository +{ + Task> GetByIdAsync(int roleId, CancellationToken cancellationToken); + + Task> GetByNameAsync(string roleName, CancellationToken cancellationToken); +} diff --git a/src/SearchBugs.Domain/Roles/Role.cs b/src/SearchBugs.Domain/Roles/Role.cs index 02ab0fb..ce38577 100644 --- a/src/SearchBugs.Domain/Roles/Role.cs +++ b/src/SearchBugs.Domain/Roles/Role.cs @@ -11,8 +11,9 @@ public sealed class Role : Enumeration public static readonly Role Reporter = new(4, "Reporter"); public static readonly Role Guest = new(5, "Guest"); - public IReadOnlyCollection Users { get; } = new List(); + public IReadOnlyCollection Users { get; set; } + public ICollection Permissions { get; set; } public Role(int id, string name) : base(id, name) diff --git a/src/SearchBugs.Domain/Users/UserRole.cs b/src/SearchBugs.Domain/Users/UserRole.cs index 45e4efb..8865570 100644 --- a/src/SearchBugs.Domain/Users/UserRole.cs +++ b/src/SearchBugs.Domain/Users/UserRole.cs @@ -14,5 +14,6 @@ private UserRole() public UserId UserId { get; private set; } public int RoleId { get; private set; } + } diff --git a/src/SearchBugs.Infrastructure/Authentication/HasPermissionAttribute.cs b/src/SearchBugs.Infrastructure/Authentication/HasPermissionAttribute.cs new file mode 100644 index 0000000..6f3423f --- /dev/null +++ b/src/SearchBugs.Infrastructure/Authentication/HasPermissionAttribute.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Authorization; + +namespace SearchBugs.Infrastructure.Authentication; + +public sealed class HasPermissionAttribute : AuthorizeAttribute +{ + public HasPermissionAttribute(string permission) : base(permission) + { + } +} diff --git a/src/SearchBugs.Infrastructure/Authentication/IPermissionService.cs b/src/SearchBugs.Infrastructure/Authentication/IPermissionService.cs new file mode 100644 index 0000000..1c26ffa --- /dev/null +++ b/src/SearchBugs.Infrastructure/Authentication/IPermissionService.cs @@ -0,0 +1,6 @@ +namespace SearchBugs.Infrastructure.Authentication; + +public interface IPermissionService +{ + Task> GetPermissionsAsync(Guid userId); +} diff --git a/src/SearchBugs.Infrastructure/Authentication/PermissionAuthorizationHandler.cs b/src/SearchBugs.Infrastructure/Authentication/PermissionAuthorizationHandler.cs new file mode 100644 index 0000000..ccad605 --- /dev/null +++ b/src/SearchBugs.Infrastructure/Authentication/PermissionAuthorizationHandler.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using System.IdentityModel.Tokens.Jwt; + +namespace SearchBugs.Infrastructure.Authentication; + +public class PermissionAuthorizationHandler : AuthorizationHandler +{ + + private readonly IServiceScopeFactory _serviceScopeFactory; + + public PermissionAuthorizationHandler(IServiceScopeFactory serviceScopeFactory) + { + _serviceScopeFactory = serviceScopeFactory; + } + + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionRequirement requirement) + { + string? userId = context.User.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Sub)?.Value; + + if (!Guid.TryParse(userId, out Guid userIdGuid)) + { + return; + } + + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + + IPermissionService permissionService = scope.ServiceProvider.GetRequiredService(); + + HashSet permissions = await permissionService.GetPermissionsAsync(userIdGuid); + + if (permissions.Contains(requirement.Permission)) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + } + +} diff --git a/src/SearchBugs.Infrastructure/Authentication/PermissionAuthorizationPolicyProvider.cs b/src/SearchBugs.Infrastructure/Authentication/PermissionAuthorizationPolicyProvider.cs new file mode 100644 index 0000000..9c70be6 --- /dev/null +++ b/src/SearchBugs.Infrastructure/Authentication/PermissionAuthorizationPolicyProvider.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; + +namespace SearchBugs.Infrastructure.Authentication; + +public class PermissionAuthorizationPolicyProvider : DefaultAuthorizationPolicyProvider +{ + public PermissionAuthorizationPolicyProvider(IOptions options) : base(options) + { + } + + public override async Task GetPolicyAsync(string policyName) + { + AuthorizationPolicy? policy = await base.GetPolicyAsync(policyName); + + if (policy is not null) + { + return policy; + } + + return new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .AddRequirements(new PermissionRequirement(policyName)) + .Build(); + } + +} diff --git a/src/SearchBugs.Infrastructure/Authentication/PermissionRequirement.cs b/src/SearchBugs.Infrastructure/Authentication/PermissionRequirement.cs new file mode 100644 index 0000000..95537fc --- /dev/null +++ b/src/SearchBugs.Infrastructure/Authentication/PermissionRequirement.cs @@ -0,0 +1,14 @@ + +using Microsoft.AspNetCore.Authorization; + +namespace SearchBugs.Infrastructure.Authentication; + +public class PermissionRequirement : IAuthorizationRequirement +{ + public string Permission { get; } + + public PermissionRequirement(string permission) + { + Permission = permission; + } +} \ No newline at end of file diff --git a/src/SearchBugs.Infrastructure/Authentication/PermissionService.cs b/src/SearchBugs.Infrastructure/Authentication/PermissionService.cs new file mode 100644 index 0000000..5bbbfce --- /dev/null +++ b/src/SearchBugs.Infrastructure/Authentication/PermissionService.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using SearchBugs.Domain.Roles; +using SearchBugs.Domain.Users; +using SearchBugs.Persistence; + +namespace SearchBugs.Infrastructure.Authentication; + +public class PermissionService : IPermissionService +{ + private readonly ApplicationDbContext _dbContext; + + public PermissionService(ApplicationDbContext dbContext) + { + _dbContext = dbContext; + } + public async Task> GetPermissionsAsync(Guid userId) + { + ICollection[] roles = (ICollection[])await _dbContext.Set() + .Include(u => u.Roles) + .ThenInclude(ur => ur.Permissions) + .Where(u => u.Id == new UserId(userId)) + .Select(u => u.Roles) + .ToArrayAsync(); + + return roles.SelectMany(r => r.SelectMany(r => r.Permissions.Select(p => p.Name))).ToHashSet(); + + } +} diff --git a/src/SearchBugs.Infrastructure/DependencyInjection.cs b/src/SearchBugs.Infrastructure/DependencyInjection.cs index a5d9c26..a756f50 100644 --- a/src/SearchBugs.Infrastructure/DependencyInjection.cs +++ b/src/SearchBugs.Infrastructure/DependencyInjection.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; using SearchBugs.Domain.Git; using SearchBugs.Domain.Services; @@ -22,6 +23,7 @@ public static void AddInfrastructure(this IServiceCollection services) //{ // options.WaitForJobsToComplete = true; //}); + services.AddAuthorization(); services.AddHttpContextAccessor(); services.ConfigureOptions(); @@ -35,7 +37,6 @@ public static void AddInfrastructure(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddCors(options => { options.AddPolicy("AllowSpecificOrigin", @@ -45,5 +46,10 @@ public static void AddInfrastructure(this IServiceCollection services) .AllowCredentials()); }); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + + } } diff --git a/src/SearchBugs.Infrastructure/SearchBugs.Infrastructure.csproj b/src/SearchBugs.Infrastructure/SearchBugs.Infrastructure.csproj index 06fa104..3bd36c3 100644 --- a/src/SearchBugs.Infrastructure/SearchBugs.Infrastructure.csproj +++ b/src/SearchBugs.Infrastructure/SearchBugs.Infrastructure.csproj @@ -17,6 +17,7 @@ + diff --git a/src/SearchBugs.Persistence/DependencyInjection.cs b/src/SearchBugs.Persistence/DependencyInjection.cs index 1951c65..ad5d0f5 100644 --- a/src/SearchBugs.Persistence/DependencyInjection.cs +++ b/src/SearchBugs.Persistence/DependencyInjection.cs @@ -6,6 +6,7 @@ using SearchBugs.Domain.Bugs; using SearchBugs.Domain.Git; using SearchBugs.Domain.Projects; +using SearchBugs.Domain.Roles; using SearchBugs.Domain.Users; using SearchBugs.Persistence.Repositories; using Shared.Data; @@ -37,6 +38,7 @@ public static IServiceCollection AddPersistence( services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddMemoryCache() .ConfigureOptions(); diff --git a/src/SearchBugs.Persistence/Repositories/Repository.cs b/src/SearchBugs.Persistence/Repositories/Repository.cs index 306b60f..de26f10 100644 --- a/src/SearchBugs.Persistence/Repositories/Repository.cs +++ b/src/SearchBugs.Persistence/Repositories/Repository.cs @@ -15,7 +15,7 @@ public abstract class Repository : IRepository Add(TEntity entity) => Result.Create(_context.Set().Add(entity)); - public async Task Update(TEntity entity) => Result.Create(_context.Set().Update(entity)); + public async Task Update(TEntity entity) => Result.Create( _context.Set().Update(entity)); public async Task Remove(TEntity entity) => Result.Create(_context.Set().Remove(entity)); diff --git a/src/SearchBugs.Persistence/Repositories/RoleRepository.cs b/src/SearchBugs.Persistence/Repositories/RoleRepository.cs new file mode 100644 index 0000000..5e9602d --- /dev/null +++ b/src/SearchBugs.Persistence/Repositories/RoleRepository.cs @@ -0,0 +1,34 @@ + +using SearchBugs.Domain.Roles; +using Shared.Results; + +namespace SearchBugs.Persistence.Repositories; + +internal sealed class RoleRepository : IRoleRepository +{ + + private readonly ApplicationDbContext _context; + + public RoleRepository(ApplicationDbContext context) + { + _context = context; + } + + public async Task> GetByIdAsync(int roleId, CancellationToken cancellationToken) + { + return Result.Create( + _context.Roles + .Where(r => r.Id == roleId) + .Select(r => new Role(r.Id, r.Name)) + .SingleOrDefault()); + } + + public async Task> GetByNameAsync(string roleName, CancellationToken cancellationToken) + { + return Result.Create( + _context.Roles + .Where(r => r.Name == roleName) + .Select(r => new Role(r.Id, r.Name)) + .SingleOrDefault()); + } +}