From c041f3f7b294befea93db8305dc4b3fd642b79ae Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 14 Jan 2026 15:51:16 +0700 Subject: [PATCH] feat: Add Access Request functionality to IAM Service - Introduced new Access Request and Access Request Approver entities in the DbContext to support access management features. - Updated Dependency Injection to include the AccessRequestRepository, enhancing the service's capabilities for handling access requests. - Added example curl command for token retrieval using the test account, improving developer experience for testing authentication flows. --- note.md | 16 +- .../AccessRequestCommandHandlers.cs | 159 +++++++++++ .../AccessRequests/AccessRequestCommands.cs | 51 ++++ .../AccessRequests/AccessRequestQueries.cs | 52 ++++ .../AccessRequestQueryHandlers.cs | 127 +++++++++ .../AccessRequestCommandValidators.cs | 111 ++++++++ .../Controllers/AccessRequestsController.cs | 246 ++++++++++++++++++ .../AccessRequestAggregate/AccessRequest.cs | 206 +++++++++++++++ .../AccessRequestApprover.cs | 94 +++++++ .../AccessRequestPriority.cs | 30 +++ .../AccessRequestStatus.cs | 73 ++++++ .../IAccessRequestRepository.cs | 24 ++ .../Events/AccessRequestEvents.cs | 57 ++++ .../DependencyInjection.cs | 2 + .../AccessRequestEntityConfiguration.cs | 138 ++++++++++ .../IamServiceContext.cs | 13 + .../Repositories/AccessRequestRepository.cs | 78 ++++++ 17 files changed, 1476 insertions(+), 1 deletion(-) create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/AccessRequests/AccessRequestCommandHandlers.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/AccessRequests/AccessRequestCommands.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Queries/AccessRequests/AccessRequestQueries.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Queries/AccessRequests/AccessRequestQueryHandlers.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Validations/AccessRequestCommandValidators.cs create mode 100644 services/iam-service-net/src/IamService.API/Controllers/AccessRequestsController.cs create mode 100644 services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/AccessRequest.cs create mode 100644 services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/AccessRequestApprover.cs create mode 100644 services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/AccessRequestPriority.cs create mode 100644 services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/AccessRequestStatus.cs create mode 100644 services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/IAccessRequestRepository.cs create mode 100644 services/iam-service-net/src/IamService.Domain/Events/AccessRequestEvents.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/EntityConfigurations/AccessRequestEntityConfiguration.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/Repositories/AccessRequestRepository.cs diff --git a/note.md b/note.md index 1c4b54ea..f014d2b3 100644 --- a/note.md +++ b/note.md @@ -1,3 +1,17 @@ Test Account Tài khoản: hongochai10@icloud.com -Mật Khẩu: Velik@2026 \ No newline at end of file +Mật Khẩu: Velik@2026 + + +curl -s -X POST "http +://localhost:5001/connect/token" \ +> -H "Content-Type: application/x-www-fo +rm-urlencoded" \ +> -d "grant_type=password" \ +> -d "client_id=password-client" \ +> -d "client_secret=password-client-secret" \ +> -d "username=hongochai10@icloud.com" \ + +> -d "password=Velik@2026" \ +> -d "scope=openid profile email api offline_access" 2> +&1 | jq . \ No newline at end of file diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/AccessRequests/AccessRequestCommandHandlers.cs b/services/iam-service-net/src/IamService.API/Application/Commands/AccessRequests/AccessRequestCommandHandlers.cs new file mode 100644 index 00000000..04a3ab04 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/AccessRequests/AccessRequestCommandHandlers.cs @@ -0,0 +1,159 @@ +using MediatR; +using IamService.Domain.AggregatesModel.AccessRequestAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.AccessRequests; + +/// +/// EN: Handler for CreateAccessRequestCommand. +/// VI: Handler cho CreateAccessRequestCommand. +/// +public class CreateAccessRequestCommandHandler : IRequestHandler +{ + private readonly IAccessRequestRepository _repository; + + public CreateAccessRequestCommandHandler(IAccessRequestRepository repository) + { + _repository = repository; + } + + public async Task Handle( + CreateAccessRequestCommand request, + CancellationToken cancellationToken) + { + var priority = request.Priority.HasValue + ? AccessRequestPriority.FromId(request.Priority.Value) + : AccessRequestPriority.Medium; + + var accessRequest = AccessRequest.Create( + request.RequesterId, + request.ResourceType, + request.ResourceId, + request.RequestedPermission, + request.Justification, + priority); + + // EN: Add approvers + // VI: Thêm approvers + foreach (var approverId in request.ApproverIds) + { + accessRequest.AddApprover(approverId); + } + + _repository.Add(accessRequest); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return new CreateAccessRequestCommandResult( + accessRequest.Id, + accessRequest.Status.Name, + accessRequest.CreatedAt); + } +} + +/// +/// EN: Handler for SubmitAccessRequestCommand. +/// VI: Handler cho SubmitAccessRequestCommand. +/// +public class SubmitAccessRequestCommandHandler : IRequestHandler +{ + private readonly IAccessRequestRepository _repository; + + public SubmitAccessRequestCommandHandler(IAccessRequestRepository repository) + { + _repository = repository; + } + + public async Task Handle(SubmitAccessRequestCommand request, CancellationToken cancellationToken) + { + var accessRequest = await _repository.GetByIdWithApproversAsync(request.RequestId, cancellationToken); + if (accessRequest == null) + throw new DomainException($"Access request {request.RequestId} not found."); + + accessRequest.Submit(); + _repository.Update(accessRequest); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Unit.Value; + } +} + +/// +/// EN: Handler for ApproveAccessRequestCommand. +/// VI: Handler cho ApproveAccessRequestCommand. +/// +public class ApproveAccessRequestCommandHandler : IRequestHandler +{ + private readonly IAccessRequestRepository _repository; + + public ApproveAccessRequestCommandHandler(IAccessRequestRepository repository) + { + _repository = repository; + } + + public async Task Handle(ApproveAccessRequestCommand request, CancellationToken cancellationToken) + { + var accessRequest = await _repository.GetByIdWithApproversAsync(request.RequestId, cancellationToken); + if (accessRequest == null) + throw new DomainException($"Access request {request.RequestId} not found."); + + accessRequest.Approve(request.ApproverId, request.Comments); + _repository.Update(accessRequest); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Unit.Value; + } +} + +/// +/// EN: Handler for RejectAccessRequestCommand. +/// VI: Handler cho RejectAccessRequestCommand. +/// +public class RejectAccessRequestCommandHandler : IRequestHandler +{ + private readonly IAccessRequestRepository _repository; + + public RejectAccessRequestCommandHandler(IAccessRequestRepository repository) + { + _repository = repository; + } + + public async Task Handle(RejectAccessRequestCommand request, CancellationToken cancellationToken) + { + var accessRequest = await _repository.GetByIdWithApproversAsync(request.RequestId, cancellationToken); + if (accessRequest == null) + throw new DomainException($"Access request {request.RequestId} not found."); + + accessRequest.Reject(request.ApproverId, request.Reason); + _repository.Update(accessRequest); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Unit.Value; + } +} + +/// +/// EN: Handler for CancelAccessRequestCommand. +/// VI: Handler cho CancelAccessRequestCommand. +/// +public class CancelAccessRequestCommandHandler : IRequestHandler +{ + private readonly IAccessRequestRepository _repository; + + public CancelAccessRequestCommandHandler(IAccessRequestRepository repository) + { + _repository = repository; + } + + public async Task Handle(CancelAccessRequestCommand request, CancellationToken cancellationToken) + { + var accessRequest = await _repository.GetByIdAsync(request.RequestId, cancellationToken); + if (accessRequest == null) + throw new DomainException($"Access request {request.RequestId} not found."); + + accessRequest.Cancel(); + _repository.Update(accessRequest); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/AccessRequests/AccessRequestCommands.cs b/services/iam-service-net/src/IamService.API/Application/Commands/AccessRequests/AccessRequestCommands.cs new file mode 100644 index 00000000..a2f1c95a --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/AccessRequests/AccessRequestCommands.cs @@ -0,0 +1,51 @@ +using MediatR; + +namespace IamService.API.Application.Commands.AccessRequests; + +/// +/// EN: Command to create a new access request. +/// VI: Command để tạo yêu cầu truy cập mới. +/// +public record CreateAccessRequestCommand( + Guid RequesterId, + string ResourceType, + Guid ResourceId, + string RequestedPermission, + string? Justification, + int? Priority, + List ApproverIds) : IRequest; + +public record CreateAccessRequestCommandResult( + Guid Id, + string Status, + DateTime CreatedAt); + +/// +/// EN: Command to submit access request for approval. +/// VI: Command để gửi yêu cầu truy cập để phê duyệt. +/// +public record SubmitAccessRequestCommand(Guid RequestId) : IRequest; + +/// +/// EN: Command to approve access request. +/// VI: Command để phê duyệt yêu cầu truy cập. +/// +public record ApproveAccessRequestCommand( + Guid RequestId, + Guid ApproverId, + string? Comments) : IRequest; + +/// +/// EN: Command to reject access request. +/// VI: Command để từ chối yêu cầu truy cập. +/// +public record RejectAccessRequestCommand( + Guid RequestId, + Guid ApproverId, + string? Reason) : IRequest; + +/// +/// EN: Command to cancel access request. +/// VI: Command để hủy yêu cầu truy cập. +/// +public record CancelAccessRequestCommand(Guid RequestId) : IRequest; diff --git a/services/iam-service-net/src/IamService.API/Application/Queries/AccessRequests/AccessRequestQueries.cs b/services/iam-service-net/src/IamService.API/Application/Queries/AccessRequests/AccessRequestQueries.cs new file mode 100644 index 00000000..007418f6 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Queries/AccessRequests/AccessRequestQueries.cs @@ -0,0 +1,52 @@ +using MediatR; + +namespace IamService.API.Application.Queries.AccessRequests; + +/// +/// EN: Query to get access request by ID. +/// VI: Query để lấy yêu cầu truy cập theo ID. +/// +public record GetAccessRequestByIdQuery(Guid Id) : IRequest; + +/// +/// EN: Query to get my access requests. +/// VI: Query để lấy yêu cầu truy cập của tôi. +/// +public record GetMyAccessRequestsQuery(Guid RequesterId) : IRequest>; + +/// +/// EN: Query to get pending approvals for current user. +/// VI: Query để lấy các yêu cầu đang chờ phê duyệt của user hiện tại. +/// +public record GetPendingApprovalsQuery(Guid ApproverId) : IRequest>; + +/// +/// EN: Access request DTO. +/// VI: DTO cho yêu cầu truy cập. +/// +public record AccessRequestDto( + Guid Id, + Guid RequesterId, + string ResourceType, + Guid ResourceId, + string RequestedPermission, + string? Justification, + string Status, + string Priority, + DateTime CreatedAt, + DateTime? SubmittedAt, + DateTime? ResolvedAt, + DateTime? ExpiresAt, + IEnumerable Approvers); + +/// +/// EN: Access request approver DTO. +/// VI: DTO cho người phê duyệt yêu cầu truy cập. +/// +public record AccessRequestApproverDto( + Guid Id, + Guid UserId, + int Order, + string Status, + DateTime? RespondedAt, + string? Comments); diff --git a/services/iam-service-net/src/IamService.API/Application/Queries/AccessRequests/AccessRequestQueryHandlers.cs b/services/iam-service-net/src/IamService.API/Application/Queries/AccessRequests/AccessRequestQueryHandlers.cs new file mode 100644 index 00000000..fab37bf8 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Queries/AccessRequests/AccessRequestQueryHandlers.cs @@ -0,0 +1,127 @@ +using MediatR; +using IamService.Domain.AggregatesModel.AccessRequestAggregate; + +namespace IamService.API.Application.Queries.AccessRequests; + +/// +/// EN: Handler for GetAccessRequestByIdQuery. +/// VI: Handler cho GetAccessRequestByIdQuery. +/// +public class GetAccessRequestByIdQueryHandler : IRequestHandler +{ + private readonly IAccessRequestRepository _repository; + + public GetAccessRequestByIdQueryHandler(IAccessRequestRepository repository) + { + _repository = repository; + } + + public async Task Handle(GetAccessRequestByIdQuery request, CancellationToken cancellationToken) + { + var accessRequest = await _repository.GetByIdWithApproversAsync(request.Id, cancellationToken); + return accessRequest != null ? MapToDto(accessRequest) : null; + } + + private static AccessRequestDto MapToDto(AccessRequest request) => new( + request.Id, + request.RequesterId, + request.ResourceType, + request.ResourceId, + request.RequestedPermission, + request.Justification, + request.Status.Name, + request.Priority.Name, + request.CreatedAt, + request.SubmittedAt, + request.ResolvedAt, + request.ExpiresAt, + request.Approvers.Select(a => new AccessRequestApproverDto( + a.Id, + a.UserId, + a.Order, + a.Status.Name, + a.RespondedAt, + a.Comments))); +} + +/// +/// EN: Handler for GetMyAccessRequestsQuery. +/// VI: Handler cho GetMyAccessRequestsQuery. +/// +public class GetMyAccessRequestsQueryHandler : IRequestHandler> +{ + private readonly IAccessRequestRepository _repository; + + public GetMyAccessRequestsQueryHandler(IAccessRequestRepository repository) + { + _repository = repository; + } + + public async Task> Handle(GetMyAccessRequestsQuery request, CancellationToken cancellationToken) + { + var requests = await _repository.GetByRequesterIdAsync(request.RequesterId, cancellationToken); + return requests.Select(MapToDto); + } + + private static AccessRequestDto MapToDto(AccessRequest request) => new( + request.Id, + request.RequesterId, + request.ResourceType, + request.ResourceId, + request.RequestedPermission, + request.Justification, + request.Status.Name, + request.Priority.Name, + request.CreatedAt, + request.SubmittedAt, + request.ResolvedAt, + request.ExpiresAt, + request.Approvers.Select(a => new AccessRequestApproverDto( + a.Id, + a.UserId, + a.Order, + a.Status.Name, + a.RespondedAt, + a.Comments))); +} + +/// +/// EN: Handler for GetPendingApprovalsQuery. +/// VI: Handler cho GetPendingApprovalsQuery. +/// +public class GetPendingApprovalsQueryHandler : IRequestHandler> +{ + private readonly IAccessRequestRepository _repository; + + public GetPendingApprovalsQueryHandler(IAccessRequestRepository repository) + { + _repository = repository; + } + + public async Task> Handle(GetPendingApprovalsQuery request, CancellationToken cancellationToken) + { + var requests = await _repository.GetPendingByApproverIdAsync(request.ApproverId, cancellationToken); + return requests.Select(MapToDto); + } + + private static AccessRequestDto MapToDto(AccessRequest request) => new( + request.Id, + request.RequesterId, + request.ResourceType, + request.ResourceId, + request.RequestedPermission, + request.Justification, + request.Status.Name, + request.Priority.Name, + request.CreatedAt, + request.SubmittedAt, + request.ResolvedAt, + request.ExpiresAt, + request.Approvers.Select(a => new AccessRequestApproverDto( + a.Id, + a.UserId, + a.Order, + a.Status.Name, + a.RespondedAt, + a.Comments))); +} diff --git a/services/iam-service-net/src/IamService.API/Application/Validations/AccessRequestCommandValidators.cs b/services/iam-service-net/src/IamService.API/Application/Validations/AccessRequestCommandValidators.cs new file mode 100644 index 00000000..a22f30d6 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Validations/AccessRequestCommandValidators.cs @@ -0,0 +1,111 @@ +using FluentValidation; +using IamService.API.Application.Commands.AccessRequests; + +namespace IamService.API.Application.Validations; + +/// +/// EN: Validator for CreateAccessRequestCommand. +/// VI: Validator cho CreateAccessRequestCommand. +/// +public class CreateAccessRequestCommandValidator : AbstractValidator +{ + private static readonly string[] ValidResourceTypes = ["Organization", "Group", "Role", "Application", "Resource"]; + + public CreateAccessRequestCommandValidator() + { + RuleFor(x => x.RequesterId) + .NotEmpty().WithMessage("Requester ID is required"); + + RuleFor(x => x.ResourceType) + .NotEmpty().WithMessage("Resource type is required") + .MaximumLength(100).WithMessage("Resource type cannot exceed 100 characters") + .Must(t => ValidResourceTypes.Contains(t)) + .WithMessage($"Resource type must be one of: {string.Join(", ", ValidResourceTypes)}"); + + RuleFor(x => x.ResourceId) + .NotEmpty().WithMessage("Resource ID is required"); + + RuleFor(x => x.RequestedPermission) + .NotEmpty().WithMessage("Requested permission is required") + .MaximumLength(100).WithMessage("Requested permission cannot exceed 100 characters"); + + RuleFor(x => x.Justification) + .MaximumLength(2000).WithMessage("Justification cannot exceed 2000 characters") + .When(x => x.Justification != null); + + RuleFor(x => x.Priority) + .Must(p => p == null || (p >= 1 && p <= 4)) + .WithMessage("Priority must be 1 (Low), 2 (Medium), 3 (High), or 4 (Critical)"); + + RuleFor(x => x.ApproverIds) + .NotEmpty().WithMessage("At least one approver is required") + .Must(ids => ids.All(id => id != Guid.Empty)) + .WithMessage("All approver IDs must be valid GUIDs"); + } +} + +/// +/// EN: Validator for ApproveAccessRequestCommand. +/// VI: Validator cho ApproveAccessRequestCommand. +/// +public class ApproveAccessRequestCommandValidator : AbstractValidator +{ + public ApproveAccessRequestCommandValidator() + { + RuleFor(x => x.RequestId) + .NotEmpty().WithMessage("Request ID is required"); + + RuleFor(x => x.ApproverId) + .NotEmpty().WithMessage("Approver ID is required"); + + RuleFor(x => x.Comments) + .MaximumLength(1000).WithMessage("Comments cannot exceed 1000 characters") + .When(x => x.Comments != null); + } +} + +/// +/// EN: Validator for RejectAccessRequestCommand. +/// VI: Validator cho RejectAccessRequestCommand. +/// +public class RejectAccessRequestCommandValidator : AbstractValidator +{ + public RejectAccessRequestCommandValidator() + { + RuleFor(x => x.RequestId) + .NotEmpty().WithMessage("Request ID is required"); + + RuleFor(x => x.ApproverId) + .NotEmpty().WithMessage("Approver ID is required"); + + RuleFor(x => x.Reason) + .MaximumLength(1000).WithMessage("Reason cannot exceed 1000 characters") + .When(x => x.Reason != null); + } +} + +/// +/// EN: Validator for SubmitAccessRequestCommand. +/// VI: Validator cho SubmitAccessRequestCommand. +/// +public class SubmitAccessRequestCommandValidator : AbstractValidator +{ + public SubmitAccessRequestCommandValidator() + { + RuleFor(x => x.RequestId) + .NotEmpty().WithMessage("Request ID is required"); + } +} + +/// +/// EN: Validator for CancelAccessRequestCommand. +/// VI: Validator cho CancelAccessRequestCommand. +/// +public class CancelAccessRequestCommandValidator : AbstractValidator +{ + public CancelAccessRequestCommandValidator() + { + RuleFor(x => x.RequestId) + .NotEmpty().WithMessage("Request ID is required"); + } +} diff --git a/services/iam-service-net/src/IamService.API/Controllers/AccessRequestsController.cs b/services/iam-service-net/src/IamService.API/Controllers/AccessRequestsController.cs new file mode 100644 index 00000000..68bb0c38 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Controllers/AccessRequestsController.cs @@ -0,0 +1,246 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using IamService.API.Application.Common; +using IamService.API.Application.Commands.AccessRequests; +using IamService.API.Application.Queries.AccessRequests; + +namespace IamService.API.Controllers; + +/// +/// EN: Access request management controller. +/// VI: Controller quản lý yêu cầu truy cập. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/access-requests")] +[Authorize(AuthenticationSchemes = "Bearer")] +[SwaggerTag("Access request management - requires authentication")] +public class AccessRequestsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public AccessRequestsController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Create a new access request. + /// VI: Tạo yêu cầu truy cập mới. + /// + [HttpPost] + [SwaggerOperation(Summary = "Create access request", OperationId = "CreateAccessRequest")] + [SwaggerResponse(StatusCodes.Status201Created, "Request created", typeof(ApiResponse))] + public async Task CreateAccessRequest( + [FromBody] CreateAccessRequestRequest request, + CancellationToken cancellationToken = default) + { + var command = new CreateAccessRequestCommand( + request.RequesterId, + request.ResourceType, + request.ResourceId, + request.RequestedPermission, + request.Justification, + request.Priority, + request.ApproverIds); + + var result = await _mediator.Send(command, cancellationToken); + + return CreatedAtAction(nameof(GetAccessRequestById), new { id = result.Id }, + ApiResponse.Ok(new AccessRequestResponse + { + Id = result.Id, + Status = result.Status, + CreatedAt = result.CreatedAt + })); + } + + /// + /// EN: Get access request by ID. + /// VI: Lấy yêu cầu truy cập theo ID. + /// + [HttpGet("{id:guid}")] + [SwaggerOperation(Summary = "Get access request by ID", OperationId = "GetAccessRequestById")] + [SwaggerResponse(StatusCodes.Status200OK, "Request found", typeof(ApiResponse))] + [SwaggerResponse(StatusCodes.Status404NotFound, "Request not found")] + public async Task GetAccessRequestById( + [FromRoute] Guid id, + CancellationToken cancellationToken = default) + { + var result = await _mediator.Send(new GetAccessRequestByIdQuery(id), cancellationToken); + if (result == null) + return NotFound(ApiResponse.Fail("REQUEST_NOT_FOUND", $"Access request {id} not found.")); + + return Ok(ApiResponse.Ok(result)); + } + + /// + /// EN: Get my access requests. + /// VI: Lấy yêu cầu truy cập của tôi. + /// + [HttpGet] + [SwaggerOperation(Summary = "Get my access requests", OperationId = "GetMyAccessRequests")] + [SwaggerResponse(StatusCodes.Status200OK, "Requests returned", typeof(ApiResponse>))] + public async Task GetMyAccessRequests( + [FromQuery] Guid requesterId, + CancellationToken cancellationToken = default) + { + var result = await _mediator.Send(new GetMyAccessRequestsQuery(requesterId), cancellationToken); + return Ok(ApiResponse>.Ok(result)); + } + + /// + /// EN: Get pending approvals for me. + /// VI: Lấy các yêu cầu đang chờ tôi phê duyệt. + /// + [HttpGet("pending")] + [SwaggerOperation(Summary = "Get pending approvals", OperationId = "GetPendingApprovals")] + [SwaggerResponse(StatusCodes.Status200OK, "Pending approvals returned", typeof(ApiResponse>))] + public async Task GetPendingApprovals( + [FromQuery] Guid approverId, + CancellationToken cancellationToken = default) + { + var result = await _mediator.Send(new GetPendingApprovalsQuery(approverId), cancellationToken); + return Ok(ApiResponse>.Ok(result)); + } + + /// + /// EN: Submit access request for approval. + /// VI: Gửi yêu cầu truy cập để phê duyệt. + /// + [HttpPost("{id:guid}/submit")] + [SwaggerOperation(Summary = "Submit access request", OperationId = "SubmitAccessRequest")] + [SwaggerResponse(StatusCodes.Status200OK, "Request submitted")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Request not found")] + public async Task SubmitAccessRequest( + [FromRoute] Guid id, + CancellationToken cancellationToken = default) + { + try + { + await _mediator.Send(new SubmitAccessRequestCommand(id), cancellationToken); + return Ok(ApiResponse.Ok(new { Message = "Access request submitted successfully." })); + } + catch (Exception ex) when (ex.Message.Contains("not found")) + { + return NotFound(ApiResponse.Fail("REQUEST_NOT_FOUND", ex.Message)); + } + } + + /// + /// EN: Approve access request. + /// VI: Phê duyệt yêu cầu truy cập. + /// + [HttpPost("{id:guid}/approve")] + [SwaggerOperation(Summary = "Approve access request", OperationId = "ApproveAccessRequest")] + [SwaggerResponse(StatusCodes.Status200OK, "Request approved")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid operation")] + public async Task ApproveAccessRequest( + [FromRoute] Guid id, + [FromBody] ApproveRejectRequest request, + CancellationToken cancellationToken = default) + { + try + { + await _mediator.Send(new ApproveAccessRequestCommand(id, request.ApproverId, request.Comments), cancellationToken); + return Ok(ApiResponse.Ok(new { Message = "Access request approved." })); + } + catch (Exception ex) when (ex.Message.Contains("not found")) + { + return NotFound(ApiResponse.Fail("REQUEST_NOT_FOUND", ex.Message)); + } + catch (Exception ex) + { + return BadRequest(ApiResponse.Fail("INVALID_OPERATION", ex.Message)); + } + } + + /// + /// EN: Reject access request. + /// VI: Từ chối yêu cầu truy cập. + /// + [HttpPost("{id:guid}/reject")] + [SwaggerOperation(Summary = "Reject access request", OperationId = "RejectAccessRequest")] + [SwaggerResponse(StatusCodes.Status200OK, "Request rejected")] + public async Task RejectAccessRequest( + [FromRoute] Guid id, + [FromBody] ApproveRejectRequest request, + CancellationToken cancellationToken = default) + { + try + { + await _mediator.Send(new RejectAccessRequestCommand(id, request.ApproverId, request.Comments), cancellationToken); + return Ok(ApiResponse.Ok(new { Message = "Access request rejected." })); + } + catch (Exception ex) when (ex.Message.Contains("not found")) + { + return NotFound(ApiResponse.Fail("REQUEST_NOT_FOUND", ex.Message)); + } + catch (Exception ex) + { + return BadRequest(ApiResponse.Fail("INVALID_OPERATION", ex.Message)); + } + } + + /// + /// EN: Cancel access request. + /// VI: Hủy yêu cầu truy cập. + /// + [HttpDelete("{id:guid}")] + [SwaggerOperation(Summary = "Cancel access request", OperationId = "CancelAccessRequest")] + [SwaggerResponse(StatusCodes.Status200OK, "Request cancelled")] + public async Task CancelAccessRequest( + [FromRoute] Guid id, + CancellationToken cancellationToken = default) + { + try + { + await _mediator.Send(new CancelAccessRequestCommand(id), cancellationToken); + return Ok(ApiResponse.Ok(new { Message = "Access request cancelled." })); + } + catch (Exception ex) when (ex.Message.Contains("not found")) + { + return NotFound(ApiResponse.Fail("REQUEST_NOT_FOUND", ex.Message)); + } + catch (Exception ex) + { + return BadRequest(ApiResponse.Fail("INVALID_OPERATION", ex.Message)); + } + } +} + +#region Request/Response Models + +public class CreateAccessRequestRequest +{ + public Guid RequesterId { get; set; } + /// Organization + public string ResourceType { get; set; } = string.Empty; + public Guid ResourceId { get; set; } + /// Admin + public string RequestedPermission { get; set; } = string.Empty; + public string? Justification { get; set; } + /// 2 + public int? Priority { get; set; } // 1=Low, 2=Medium, 3=High, 4=Critical + public List ApproverIds { get; set; } = []; +} + +public class ApproveRejectRequest +{ + public Guid ApproverId { get; set; } + public string? Comments { get; set; } +} + +public class AccessRequestResponse +{ + public Guid Id { get; set; } + public string Status { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } +} + +#endregion diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/AccessRequest.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/AccessRequest.cs new file mode 100644 index 00000000..e9a40094 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/AccessRequest.cs @@ -0,0 +1,206 @@ +using IamService.Domain.Events; +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.AccessRequestAggregate; + +/// +/// EN: Access request aggregate root for managing access request workflows. +/// VI: Aggregate root yêu cầu truy cập để quản lý workflow yêu cầu truy cập. +/// +public class AccessRequest : Entity, IAggregateRoot +{ + private Guid _requesterId; + private string _resourceType = null!; + private Guid _resourceId; + private string _requestedPermission = null!; + private string? _justification; + private AccessRequestStatus _status = null!; + private AccessRequestPriority _priority = null!; + private DateTime _createdAt; + private DateTime? _submittedAt; + private DateTime? _resolvedAt; + private DateTime? _expiresAt; + + private readonly List _approvers = []; + + #region Properties + + public Guid RequesterId => _requesterId; + public string ResourceType => _resourceType; + public Guid ResourceId => _resourceId; + public string RequestedPermission => _requestedPermission; + public string? Justification => _justification; + public AccessRequestStatus Status => _status; + public AccessRequestPriority Priority => _priority; + public DateTime CreatedAt => _createdAt; + public DateTime? SubmittedAt => _submittedAt; + public DateTime? ResolvedAt => _resolvedAt; + public DateTime? ExpiresAt => _expiresAt; + public IReadOnlyCollection Approvers => _approvers.AsReadOnly(); + + #endregion + + protected AccessRequest() { } + + private AccessRequest( + Guid requesterId, + string resourceType, + Guid resourceId, + string requestedPermission, + string? justification, + AccessRequestPriority? priority) + { + Id = Guid.NewGuid(); + _requesterId = requesterId; + _resourceType = resourceType; + _resourceId = resourceId; + _requestedPermission = requestedPermission; + _justification = justification; + _priority = priority ?? AccessRequestPriority.Medium; + _status = AccessRequestStatus.Draft; + _createdAt = DateTime.UtcNow; + + AddDomainEvent(new AccessRequestCreatedEvent(Id, requesterId, resourceType, resourceId, requestedPermission)); + } + + /// + /// EN: Factory method to create access request. + /// VI: Factory method để tạo yêu cầu truy cập. + /// + public static AccessRequest Create( + Guid requesterId, + string resourceType, + Guid resourceId, + string requestedPermission, + string? justification = null, + AccessRequestPriority? priority = null) + { + if (requesterId == Guid.Empty) + throw new ArgumentException("Requester ID cannot be empty", nameof(requesterId)); + if (string.IsNullOrWhiteSpace(resourceType)) + throw new ArgumentException("Resource type cannot be empty", nameof(resourceType)); + if (resourceId == Guid.Empty) + throw new ArgumentException("Resource ID cannot be empty", nameof(resourceId)); + if (string.IsNullOrWhiteSpace(requestedPermission)) + throw new ArgumentException("Requested permission cannot be empty", nameof(requestedPermission)); + + return new AccessRequest(requesterId, resourceType, resourceId, requestedPermission, justification, priority); + } + + /// + /// EN: Add an approver to the approval chain. + /// VI: Thêm người phê duyệt vào chuỗi phê duyệt. + /// + public AccessRequestApprover AddApprover(Guid userId) + { + if (_status != AccessRequestStatus.Draft) + throw new InvalidOperationException("Cannot add approvers after request is submitted"); + + var order = _approvers.Count + 1; + var approver = new AccessRequestApprover(Id, userId, order); + _approvers.Add(approver); + return approver; + } + + /// + /// EN: Submit request for approval. + /// VI: Gửi yêu cầu để phê duyệt. + /// + public void Submit(int expirationDays = 7) + { + if (_status != AccessRequestStatus.Draft) + throw new InvalidOperationException("Only draft requests can be submitted"); + + if (_approvers.Count == 0) + throw new InvalidOperationException("At least one approver is required"); + + _status = AccessRequestStatus.Pending; + _submittedAt = DateTime.UtcNow; + _expiresAt = DateTime.UtcNow.AddDays(expirationDays); + + AddDomainEvent(new AccessRequestSubmittedEvent(Id, _requesterId, _approvers.Select(a => a.UserId).ToList())); + } + + /// + /// EN: Approve the request by an approver. + /// VI: Phê duyệt yêu cầu bởi người phê duyệt. + /// + public void Approve(Guid approverId, string? comments = null) + { + if (_status != AccessRequestStatus.Pending) + throw new InvalidOperationException("Only pending requests can be approved"); + + var approver = _approvers.FirstOrDefault(a => a.UserId == approverId && a.Status == ApproverStatus.Pending); + if (approver == null) + throw new InvalidOperationException("User is not a pending approver for this request"); + + approver.Approve(comments); + + // EN: Check if all approvers have approved + // VI: Kiểm tra xem tất cả approvers đã approve chưa + if (_approvers.All(a => a.Status == ApproverStatus.Approved)) + { + _status = AccessRequestStatus.Approved; + _resolvedAt = DateTime.UtcNow; + AddDomainEvent(new AccessRequestApprovedEvent(Id, _requesterId, _resourceType, _resourceId, _requestedPermission)); + } + } + + /// + /// EN: Reject the request. + /// VI: Từ chối yêu cầu. + /// + public void Reject(Guid approverId, string? reason = null) + { + if (_status != AccessRequestStatus.Pending) + throw new InvalidOperationException("Only pending requests can be rejected"); + + var approver = _approvers.FirstOrDefault(a => a.UserId == approverId && a.Status == ApproverStatus.Pending); + if (approver == null) + throw new InvalidOperationException("User is not a pending approver for this request"); + + approver.Reject(reason); + _status = AccessRequestStatus.Rejected; + _resolvedAt = DateTime.UtcNow; + + AddDomainEvent(new AccessRequestRejectedEvent(Id, _requesterId, approverId, reason)); + } + + /// + /// EN: Cancel the request (by requester). + /// VI: Hủy yêu cầu (bởi người yêu cầu). + /// + public void Cancel() + { + if (_status.IsTerminal) + throw new InvalidOperationException("Cannot cancel a terminal request"); + + _status = AccessRequestStatus.Cancelled; + _resolvedAt = DateTime.UtcNow; + } + + /// + /// EN: Mark request as expired. + /// VI: Đánh dấu yêu cầu đã hết hạn. + /// + public void Expire() + { + if (_status != AccessRequestStatus.Pending) + throw new InvalidOperationException("Only pending requests can expire"); + + _status = AccessRequestStatus.Expired; + _resolvedAt = DateTime.UtcNow; + } + + /// + /// EN: Update justification. + /// VI: Cập nhật lý do. + /// + public void UpdateJustification(string justification) + { + if (_status != AccessRequestStatus.Draft) + throw new InvalidOperationException("Cannot update submitted request"); + + _justification = justification; + } +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/AccessRequestApprover.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/AccessRequestApprover.cs new file mode 100644 index 00000000..2b9ba53f --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/AccessRequestApprover.cs @@ -0,0 +1,94 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.AccessRequestAggregate; + +/// +/// EN: Approver for an access request. +/// VI: Người phê duyệt cho yêu cầu truy cập. +/// +public class AccessRequestApprover : Entity +{ + private Guid _requestId; + private Guid _userId; + private int _order; + private ApproverStatus _status; + private DateTime? _respondedAt; + private string? _comments; + + /// + /// EN: Access request ID. + /// VI: ID yêu cầu truy cập. + /// + public Guid RequestId => _requestId; + + /// + /// EN: Approver user ID. + /// VI: ID user phê duyệt. + /// + public Guid UserId => _userId; + + /// + /// EN: Order in approval chain (1 = first). + /// VI: Thứ tự trong chuỗi phê duyệt (1 = đầu tiên). + /// + public int Order => _order; + + /// + /// EN: Approver status. + /// VI: Trạng thái phê duyệt. + /// + public ApproverStatus Status => _status; + + /// + /// EN: When approver responded. + /// VI: Thời gian phản hồi. + /// + public DateTime? RespondedAt => _respondedAt; + + /// + /// EN: Approver comments. + /// VI: Nhận xét của người phê duyệt. + /// + public string? Comments => _comments; + + protected AccessRequestApprover() + { + _status = ApproverStatus.Pending; + } + + public AccessRequestApprover(Guid requestId, Guid userId, int order) + { + Id = Guid.NewGuid(); + _requestId = requestId; + _userId = userId; + _order = order; + _status = ApproverStatus.Pending; + } + + public void Approve(string? comments = null) + { + _status = ApproverStatus.Approved; + _respondedAt = DateTime.UtcNow; + _comments = comments; + } + + public void Reject(string? comments = null) + { + _status = ApproverStatus.Rejected; + _respondedAt = DateTime.UtcNow; + _comments = comments; + } +} + +/// +/// EN: Approver status enumeration. +/// VI: Enumeration trạng thái phê duyệt. +/// +public class ApproverStatus : Enumeration +{ + public static readonly ApproverStatus Pending = new(1, nameof(Pending)); + public static readonly ApproverStatus Approved = new(2, nameof(Approved)); + public static readonly ApproverStatus Rejected = new(3, nameof(Rejected)); + + public ApproverStatus(int id, string name) : base(id, name) { } +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/AccessRequestPriority.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/AccessRequestPriority.cs new file mode 100644 index 00000000..08f30c82 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/AccessRequestPriority.cs @@ -0,0 +1,30 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.AccessRequestAggregate; + +/// +/// EN: Access request priority enumeration. +/// VI: Enumeration mức độ ưu tiên yêu cầu truy cập. +/// +public class AccessRequestPriority : Enumeration +{ + public static readonly AccessRequestPriority Low = new(1, nameof(Low)); + public static readonly AccessRequestPriority Medium = new(2, nameof(Medium)); + public static readonly AccessRequestPriority High = new(3, nameof(High)); + public static readonly AccessRequestPriority Critical = new(4, nameof(Critical)); + + public AccessRequestPriority(int id, string name) : base(id, name) + { + } + + public static IEnumerable GetAll() => [Low, Medium, High, Critical]; + + public static AccessRequestPriority? FromId(int id) => id switch + { + 1 => Low, + 2 => Medium, + 3 => High, + 4 => Critical, + _ => null + }; +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/AccessRequestStatus.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/AccessRequestStatus.cs new file mode 100644 index 00000000..6e94e58b --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/AccessRequestStatus.cs @@ -0,0 +1,73 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.AccessRequestAggregate; + +/// +/// EN: Access request status enumeration. +/// VI: Enumeration trạng thái yêu cầu truy cập. +/// +public class AccessRequestStatus : Enumeration +{ + /// + /// EN: Request is being drafted, not yet submitted. + /// VI: Request đang được soạn, chưa gửi. + /// + public static readonly AccessRequestStatus Draft = new(1, nameof(Draft)); + + /// + /// EN: Request submitted and waiting for approval. + /// VI: Request đã gửi và đang chờ phê duyệt. + /// + public static readonly AccessRequestStatus Pending = new(2, nameof(Pending)); + + /// + /// EN: Request approved by all approvers. + /// VI: Request được phê duyệt bởi tất cả approver. + /// + public static readonly AccessRequestStatus Approved = new(3, nameof(Approved)); + + /// + /// EN: Request rejected by an approver. + /// VI: Request bị từ chối bởi approver. + /// + public static readonly AccessRequestStatus Rejected = new(4, nameof(Rejected)); + + /// + /// EN: Request cancelled by requester. + /// VI: Request bị hủy bởi người yêu cầu. + /// + public static readonly AccessRequestStatus Cancelled = new(5, nameof(Cancelled)); + + /// + /// EN: Request expired due to timeout. + /// VI: Request hết hạn do quá thời gian. + /// + public static readonly AccessRequestStatus Expired = new(6, nameof(Expired)); + + public AccessRequestStatus(int id, string name) : base(id, name) + { + } + + public static IEnumerable GetAll() => + [ + Draft, + Pending, + Approved, + Rejected, + Cancelled, + Expired + ]; + + public static AccessRequestStatus? FromId(int id) => id switch + { + 1 => Draft, + 2 => Pending, + 3 => Approved, + 4 => Rejected, + 5 => Cancelled, + 6 => Expired, + _ => null + }; + + public bool IsTerminal => this == Approved || this == Rejected || this == Cancelled || this == Expired; +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/IAccessRequestRepository.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/IAccessRequestRepository.cs new file mode 100644 index 00000000..7d44e2a9 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/AccessRequestAggregate/IAccessRequestRepository.cs @@ -0,0 +1,24 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.AccessRequestAggregate; + +/// +/// EN: Repository interface for AccessRequest aggregate. +/// VI: Interface repository cho AccessRequest aggregate. +/// +public interface IAccessRequestRepository : IRepository +{ + AccessRequest Add(AccessRequest request); + + void Update(AccessRequest request); + + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + Task GetByIdWithApproversAsync(Guid id, CancellationToken cancellationToken = default); + + Task> GetByRequesterIdAsync(Guid requesterId, CancellationToken cancellationToken = default); + + Task> GetPendingByApproverIdAsync(Guid approverId, CancellationToken cancellationToken = default); + + Task> GetExpiredRequestsAsync(CancellationToken cancellationToken = default); +} diff --git a/services/iam-service-net/src/IamService.Domain/Events/AccessRequestEvents.cs b/services/iam-service-net/src/IamService.Domain/Events/AccessRequestEvents.cs new file mode 100644 index 00000000..f8b2fcac --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/Events/AccessRequestEvents.cs @@ -0,0 +1,57 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.Events; + +/// +/// EN: Event when access request is created. +/// VI: Event khi yêu cầu truy cập được tạo. +/// +public record AccessRequestCreatedEvent( + Guid RequestId, + Guid RequesterId, + string ResourceType, + Guid ResourceId, + string RequestedPermission) : IDomainEvent +{ + public DateTime OccurredOn { get; } = DateTime.UtcNow; +} + +/// +/// EN: Event when access request is submitted for approval. +/// VI: Event khi yêu cầu truy cập được gửi để phê duyệt. +/// +public record AccessRequestSubmittedEvent( + Guid RequestId, + Guid RequesterId, + IReadOnlyList ApproverIds) : IDomainEvent +{ + public DateTime OccurredOn { get; } = DateTime.UtcNow; +} + +/// +/// EN: Event when access request is approved. +/// VI: Event khi yêu cầu truy cập được phê duyệt. +/// +public record AccessRequestApprovedEvent( + Guid RequestId, + Guid RequesterId, + string ResourceType, + Guid ResourceId, + string Permission) : IDomainEvent +{ + public DateTime OccurredOn { get; } = DateTime.UtcNow; +} + +/// +/// EN: Event when access request is rejected. +/// VI: Event khi yêu cầu truy cập bị từ chối. +/// +public record AccessRequestRejectedEvent( + Guid RequestId, + Guid RequesterId, + Guid RejectedByUserId, + string? Reason) : IDomainEvent +{ + public DateTime OccurredOn { get; } = DateTime.UtcNow; +} + diff --git a/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs index 4338dbaa..ce47bbce 100644 --- a/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs +++ b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs @@ -10,6 +10,7 @@ using IamService.Domain.AggregatesModel.RoleAggregate; using IamService.Domain.AggregatesModel.OrganizationAggregate; using IamService.Domain.AggregatesModel.GroupAggregate; using IamService.Domain.AggregatesModel.VerificationAggregate; +using IamService.Domain.AggregatesModel.AccessRequestAggregate; using IamService.Domain.SeedWork; using IamService.Infrastructure.Email; using IamService.Infrastructure.IdentityServer; @@ -153,6 +154,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); // EN: Configure Redis caching (skip in Testing environment) diff --git a/services/iam-service-net/src/IamService.Infrastructure/EntityConfigurations/AccessRequestEntityConfiguration.cs b/services/iam-service-net/src/IamService.Infrastructure/EntityConfigurations/AccessRequestEntityConfiguration.cs new file mode 100644 index 00000000..c6c59956 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/EntityConfigurations/AccessRequestEntityConfiguration.cs @@ -0,0 +1,138 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using IamService.Domain.AggregatesModel.AccessRequestAggregate; + +namespace IamService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for AccessRequest. +/// VI: Cấu hình entity cho AccessRequest. +/// +public class AccessRequestEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("AccessRequests"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .ValueGeneratedNever(); + + builder.Property("_requesterId") + .HasColumnName("RequesterId") + .IsRequired(); + + builder.Property("_resourceType") + .HasColumnName("ResourceType") + .HasMaxLength(100) + .IsRequired(); + + builder.Property("_resourceId") + .HasColumnName("ResourceId") + .IsRequired(); + + builder.Property("_requestedPermission") + .HasColumnName("RequestedPermission") + .HasMaxLength(100) + .IsRequired(); + + builder.Property("_justification") + .HasColumnName("Justification") + .HasMaxLength(2000); + + builder.Property("_createdAt") + .HasColumnName("CreatedAt") + .IsRequired(); + + builder.Property("_submittedAt") + .HasColumnName("SubmittedAt"); + + builder.Property("_resolvedAt") + .HasColumnName("ResolvedAt"); + + builder.Property("_expiresAt") + .HasColumnName("ExpiresAt"); + + // EN: Status with value conversion to use proper static instances + // VI: Status với value conversion để dùng đúng static instances + builder.Property("_status") + .HasColumnName("StatusId") + .HasConversion( + v => v.Id, + v => AccessRequestStatus.FromId(v) ?? AccessRequestStatus.Draft); + + // EN: Priority with value conversion + // VI: Priority với value conversion + builder.Property("_priority") + .HasColumnName("PriorityId") + .HasConversion( + v => v.Id, + v => AccessRequestPriority.FromId(v) ?? AccessRequestPriority.Medium); + + // EN: Approvers collection + // VI: Collection approvers + builder.HasMany(x => x.Approvers) + .WithOne() + .HasForeignKey("_requestId") + .OnDelete(DeleteBehavior.Cascade); + + // EN: Ignore domain events + // VI: Bỏ qua domain events + builder.Ignore(x => x.DomainEvents); + + // EN: Indexes + // VI: Indexes + builder.HasIndex("_requesterId").HasDatabaseName("IX_AccessRequests_RequesterId"); + builder.HasIndex("_resourceType", "_resourceId").HasDatabaseName("IX_AccessRequests_Resource"); + } +} + + +/// +/// EN: Entity configuration for AccessRequestApprover. +/// VI: Cấu hình entity cho AccessRequestApprover. +/// +public class AccessRequestApproverEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("AccessRequestApprovers"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .ValueGeneratedNever(); + + builder.Property("_requestId") + .HasColumnName("RequestId") + .IsRequired(); + + builder.Property("_userId") + .HasColumnName("UserId") + .IsRequired(); + + builder.Property("_order") + .HasColumnName("ApprovalOrder") + .IsRequired(); + + builder.Property("_respondedAt") + .HasColumnName("RespondedAt"); + + builder.Property("_comments") + .HasColumnName("Comments") + .HasMaxLength(1000); + + // EN: Status with value conversion + // VI: Status với value conversion + builder.Property("_status") + .HasColumnName("StatusId") + .HasConversion( + v => v.Id, + v => v == 1 ? ApproverStatus.Pending : v == 2 ? ApproverStatus.Approved : ApproverStatus.Rejected); + + builder.Ignore(x => x.DomainEvents); + + builder.HasIndex("_userId").HasDatabaseName("IX_AccessRequestApprovers_UserId"); + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs b/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs index 89c27ee6..c5935683 100644 --- a/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs +++ b/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs @@ -8,6 +8,7 @@ using IamService.Domain.AggregatesModel.RoleAggregate; using IamService.Domain.AggregatesModel.OrganizationAggregate; using IamService.Domain.AggregatesModel.GroupAggregate; using IamService.Domain.AggregatesModel.VerificationAggregate; +using IamService.Domain.AggregatesModel.AccessRequestAggregate; using IamService.Domain.SeedWork; namespace IamService.Infrastructure; @@ -112,6 +113,18 @@ public class IamServiceContext : IdentityDbContext public DbSet ProfileAttributeTypes { get; set; } = null!; + /// + /// EN: Access requests table. + /// VI: Bảng yêu cầu truy cập. + /// + public DbSet AccessRequests { get; set; } = null!; + + /// + /// EN: Access request approvers table. + /// VI: Bảng người phê duyệt yêu cầu truy cập. + /// + public DbSet AccessRequestApprovers { get; set; } = null!; + /// /// EN: Check if there's an active transaction. /// VI: Kiểm tra xem có transaction đang hoạt động không. diff --git a/services/iam-service-net/src/IamService.Infrastructure/Repositories/AccessRequestRepository.cs b/services/iam-service-net/src/IamService.Infrastructure/Repositories/AccessRequestRepository.cs new file mode 100644 index 00000000..b5fdfe89 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Repositories/AccessRequestRepository.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using IamService.Domain.AggregatesModel.AccessRequestAggregate; +using IamService.Domain.SeedWork; + +namespace IamService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for AccessRequest aggregate. +/// VI: Repository implementation cho AccessRequest aggregate. +/// +public class AccessRequestRepository : IAccessRequestRepository +{ + private readonly IamServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public AccessRequestRepository(IamServiceContext context) + { + _context = context; + } + + public AccessRequest Add(AccessRequest request) + { + return _context.AccessRequests.Add(request).Entity; + } + + public void Update(AccessRequest request) + { + _context.Entry(request).State = EntityState.Modified; + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.AccessRequests + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + public async Task GetByIdWithApproversAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.AccessRequests + .Include(x => x.Approvers) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + public async Task> GetByRequesterIdAsync( + Guid requesterId, + CancellationToken cancellationToken = default) + { + return await _context.AccessRequests + .Include(x => x.Approvers) + .Where(x => EF.Property(x, "_requesterId") == requesterId) + .OrderByDescending(x => EF.Property(x, "_createdAt")) + .ToListAsync(cancellationToken); + } + + public async Task> GetPendingByApproverIdAsync( + Guid approverId, + CancellationToken cancellationToken = default) + { + return await _context.AccessRequests + .Include(x => x.Approvers) + .Where(x => x.Approvers.Any(a => + EF.Property(a, "_userId") == approverId && + EF.Property(a, "_status") == ApproverStatus.Pending)) + .Where(x => EF.Property(x, "_status") == AccessRequestStatus.Pending) + .OrderByDescending(x => EF.Property(x, "_createdAt")) + .ToListAsync(cancellationToken); + } + + public async Task> GetExpiredRequestsAsync(CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + return await _context.AccessRequests + .Where(x => EF.Property(x, "_status") == AccessRequestStatus.Pending) + .Where(x => EF.Property(x, "_expiresAt") < now) + .ToListAsync(cancellationToken); + } +}