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.
This commit is contained in:
Ho Ngoc Hai
2026-01-14 15:51:16 +07:00
parent dfaf6b059b
commit c041f3f7b2
17 changed files with 1476 additions and 1 deletions

View File

@@ -0,0 +1,159 @@
using MediatR;
using IamService.Domain.AggregatesModel.AccessRequestAggregate;
using IamService.Domain.Exceptions;
namespace IamService.API.Application.Commands.AccessRequests;
/// <summary>
/// EN: Handler for CreateAccessRequestCommand.
/// VI: Handler cho CreateAccessRequestCommand.
/// </summary>
public class CreateAccessRequestCommandHandler : IRequestHandler<CreateAccessRequestCommand, CreateAccessRequestCommandResult>
{
private readonly IAccessRequestRepository _repository;
public CreateAccessRequestCommandHandler(IAccessRequestRepository repository)
{
_repository = repository;
}
public async Task<CreateAccessRequestCommandResult> 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);
}
}
/// <summary>
/// EN: Handler for SubmitAccessRequestCommand.
/// VI: Handler cho SubmitAccessRequestCommand.
/// </summary>
public class SubmitAccessRequestCommandHandler : IRequestHandler<SubmitAccessRequestCommand, Unit>
{
private readonly IAccessRequestRepository _repository;
public SubmitAccessRequestCommandHandler(IAccessRequestRepository repository)
{
_repository = repository;
}
public async Task<Unit> 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;
}
}
/// <summary>
/// EN: Handler for ApproveAccessRequestCommand.
/// VI: Handler cho ApproveAccessRequestCommand.
/// </summary>
public class ApproveAccessRequestCommandHandler : IRequestHandler<ApproveAccessRequestCommand, Unit>
{
private readonly IAccessRequestRepository _repository;
public ApproveAccessRequestCommandHandler(IAccessRequestRepository repository)
{
_repository = repository;
}
public async Task<Unit> 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;
}
}
/// <summary>
/// EN: Handler for RejectAccessRequestCommand.
/// VI: Handler cho RejectAccessRequestCommand.
/// </summary>
public class RejectAccessRequestCommandHandler : IRequestHandler<RejectAccessRequestCommand, Unit>
{
private readonly IAccessRequestRepository _repository;
public RejectAccessRequestCommandHandler(IAccessRequestRepository repository)
{
_repository = repository;
}
public async Task<Unit> 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;
}
}
/// <summary>
/// EN: Handler for CancelAccessRequestCommand.
/// VI: Handler cho CancelAccessRequestCommand.
/// </summary>
public class CancelAccessRequestCommandHandler : IRequestHandler<CancelAccessRequestCommand, Unit>
{
private readonly IAccessRequestRepository _repository;
public CancelAccessRequestCommandHandler(IAccessRequestRepository repository)
{
_repository = repository;
}
public async Task<Unit> 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;
}
}

View File

@@ -0,0 +1,51 @@
using MediatR;
namespace IamService.API.Application.Commands.AccessRequests;
/// <summary>
/// EN: Command to create a new access request.
/// VI: Command để tạo yêu cầu truy cập mới.
/// </summary>
public record CreateAccessRequestCommand(
Guid RequesterId,
string ResourceType,
Guid ResourceId,
string RequestedPermission,
string? Justification,
int? Priority,
List<Guid> ApproverIds) : IRequest<CreateAccessRequestCommandResult>;
public record CreateAccessRequestCommandResult(
Guid Id,
string Status,
DateTime CreatedAt);
/// <summary>
/// EN: Command to submit access request for approval.
/// VI: Command để gửi yêu cầu truy cập để phê duyệt.
/// </summary>
public record SubmitAccessRequestCommand(Guid RequestId) : IRequest<Unit>;
/// <summary>
/// EN: Command to approve access request.
/// VI: Command để phê duyệt yêu cầu truy cập.
/// </summary>
public record ApproveAccessRequestCommand(
Guid RequestId,
Guid ApproverId,
string? Comments) : IRequest<Unit>;
/// <summary>
/// EN: Command to reject access request.
/// VI: Command để từ chối yêu cầu truy cập.
/// </summary>
public record RejectAccessRequestCommand(
Guid RequestId,
Guid ApproverId,
string? Reason) : IRequest<Unit>;
/// <summary>
/// EN: Command to cancel access request.
/// VI: Command để hủy yêu cầu truy cập.
/// </summary>
public record CancelAccessRequestCommand(Guid RequestId) : IRequest<Unit>;

View File

@@ -0,0 +1,52 @@
using MediatR;
namespace IamService.API.Application.Queries.AccessRequests;
/// <summary>
/// EN: Query to get access request by ID.
/// VI: Query để lấy yêu cầu truy cập theo ID.
/// </summary>
public record GetAccessRequestByIdQuery(Guid Id) : IRequest<AccessRequestDto?>;
/// <summary>
/// EN: Query to get my access requests.
/// VI: Query để lấy yêu cầu truy cập của tôi.
/// </summary>
public record GetMyAccessRequestsQuery(Guid RequesterId) : IRequest<IEnumerable<AccessRequestDto>>;
/// <summary>
/// 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.
/// </summary>
public record GetPendingApprovalsQuery(Guid ApproverId) : IRequest<IEnumerable<AccessRequestDto>>;
/// <summary>
/// EN: Access request DTO.
/// VI: DTO cho yêu cầu truy cập.
/// </summary>
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<AccessRequestApproverDto> Approvers);
/// <summary>
/// EN: Access request approver DTO.
/// VI: DTO cho người phê duyệt yêu cầu truy cập.
/// </summary>
public record AccessRequestApproverDto(
Guid Id,
Guid UserId,
int Order,
string Status,
DateTime? RespondedAt,
string? Comments);

View File

@@ -0,0 +1,127 @@
using MediatR;
using IamService.Domain.AggregatesModel.AccessRequestAggregate;
namespace IamService.API.Application.Queries.AccessRequests;
/// <summary>
/// EN: Handler for GetAccessRequestByIdQuery.
/// VI: Handler cho GetAccessRequestByIdQuery.
/// </summary>
public class GetAccessRequestByIdQueryHandler : IRequestHandler<GetAccessRequestByIdQuery, AccessRequestDto?>
{
private readonly IAccessRequestRepository _repository;
public GetAccessRequestByIdQueryHandler(IAccessRequestRepository repository)
{
_repository = repository;
}
public async Task<AccessRequestDto?> 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)));
}
/// <summary>
/// EN: Handler for GetMyAccessRequestsQuery.
/// VI: Handler cho GetMyAccessRequestsQuery.
/// </summary>
public class GetMyAccessRequestsQueryHandler : IRequestHandler<GetMyAccessRequestsQuery, IEnumerable<AccessRequestDto>>
{
private readonly IAccessRequestRepository _repository;
public GetMyAccessRequestsQueryHandler(IAccessRequestRepository repository)
{
_repository = repository;
}
public async Task<IEnumerable<AccessRequestDto>> 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)));
}
/// <summary>
/// EN: Handler for GetPendingApprovalsQuery.
/// VI: Handler cho GetPendingApprovalsQuery.
/// </summary>
public class GetPendingApprovalsQueryHandler : IRequestHandler<GetPendingApprovalsQuery, IEnumerable<AccessRequestDto>>
{
private readonly IAccessRequestRepository _repository;
public GetPendingApprovalsQueryHandler(IAccessRequestRepository repository)
{
_repository = repository;
}
public async Task<IEnumerable<AccessRequestDto>> 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)));
}

View File

@@ -0,0 +1,111 @@
using FluentValidation;
using IamService.API.Application.Commands.AccessRequests;
namespace IamService.API.Application.Validations;
/// <summary>
/// EN: Validator for CreateAccessRequestCommand.
/// VI: Validator cho CreateAccessRequestCommand.
/// </summary>
public class CreateAccessRequestCommandValidator : AbstractValidator<CreateAccessRequestCommand>
{
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");
}
}
/// <summary>
/// EN: Validator for ApproveAccessRequestCommand.
/// VI: Validator cho ApproveAccessRequestCommand.
/// </summary>
public class ApproveAccessRequestCommandValidator : AbstractValidator<ApproveAccessRequestCommand>
{
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);
}
}
/// <summary>
/// EN: Validator for RejectAccessRequestCommand.
/// VI: Validator cho RejectAccessRequestCommand.
/// </summary>
public class RejectAccessRequestCommandValidator : AbstractValidator<RejectAccessRequestCommand>
{
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);
}
}
/// <summary>
/// EN: Validator for SubmitAccessRequestCommand.
/// VI: Validator cho SubmitAccessRequestCommand.
/// </summary>
public class SubmitAccessRequestCommandValidator : AbstractValidator<SubmitAccessRequestCommand>
{
public SubmitAccessRequestCommandValidator()
{
RuleFor(x => x.RequestId)
.NotEmpty().WithMessage("Request ID is required");
}
}
/// <summary>
/// EN: Validator for CancelAccessRequestCommand.
/// VI: Validator cho CancelAccessRequestCommand.
/// </summary>
public class CancelAccessRequestCommandValidator : AbstractValidator<CancelAccessRequestCommand>
{
public CancelAccessRequestCommandValidator()
{
RuleFor(x => x.RequestId)
.NotEmpty().WithMessage("Request ID is required");
}
}

View File

@@ -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;
/// <summary>
/// EN: Access request management controller.
/// VI: Controller quản lý yêu cầu truy cập.
/// </summary>
[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<AccessRequestsController> _logger;
public AccessRequestsController(IMediator mediator, ILogger<AccessRequestsController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Create a new access request.
/// VI: Tạo yêu cầu truy cập mới.
/// </summary>
[HttpPost]
[SwaggerOperation(Summary = "Create access request", OperationId = "CreateAccessRequest")]
[SwaggerResponse(StatusCodes.Status201Created, "Request created", typeof(ApiResponse<AccessRequestResponse>))]
public async Task<IActionResult> 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<AccessRequestResponse>.Ok(new AccessRequestResponse
{
Id = result.Id,
Status = result.Status,
CreatedAt = result.CreatedAt
}));
}
/// <summary>
/// EN: Get access request by ID.
/// VI: Lấy yêu cầu truy cập theo ID.
/// </summary>
[HttpGet("{id:guid}")]
[SwaggerOperation(Summary = "Get access request by ID", OperationId = "GetAccessRequestById")]
[SwaggerResponse(StatusCodes.Status200OK, "Request found", typeof(ApiResponse<AccessRequestDto>))]
[SwaggerResponse(StatusCodes.Status404NotFound, "Request not found")]
public async Task<IActionResult> GetAccessRequestById(
[FromRoute] Guid id,
CancellationToken cancellationToken = default)
{
var result = await _mediator.Send(new GetAccessRequestByIdQuery(id), cancellationToken);
if (result == null)
return NotFound(ApiResponse<AccessRequestDto>.Fail("REQUEST_NOT_FOUND", $"Access request {id} not found."));
return Ok(ApiResponse<AccessRequestDto>.Ok(result));
}
/// <summary>
/// EN: Get my access requests.
/// VI: Lấy yêu cầu truy cập của tôi.
/// </summary>
[HttpGet]
[SwaggerOperation(Summary = "Get my access requests", OperationId = "GetMyAccessRequests")]
[SwaggerResponse(StatusCodes.Status200OK, "Requests returned", typeof(ApiResponse<IEnumerable<AccessRequestDto>>))]
public async Task<IActionResult> GetMyAccessRequests(
[FromQuery] Guid requesterId,
CancellationToken cancellationToken = default)
{
var result = await _mediator.Send(new GetMyAccessRequestsQuery(requesterId), cancellationToken);
return Ok(ApiResponse<IEnumerable<AccessRequestDto>>.Ok(result));
}
/// <summary>
/// EN: Get pending approvals for me.
/// VI: Lấy các yêu cầu đang chờ tôi phê duyệt.
/// </summary>
[HttpGet("pending")]
[SwaggerOperation(Summary = "Get pending approvals", OperationId = "GetPendingApprovals")]
[SwaggerResponse(StatusCodes.Status200OK, "Pending approvals returned", typeof(ApiResponse<IEnumerable<AccessRequestDto>>))]
public async Task<IActionResult> GetPendingApprovals(
[FromQuery] Guid approverId,
CancellationToken cancellationToken = default)
{
var result = await _mediator.Send(new GetPendingApprovalsQuery(approverId), cancellationToken);
return Ok(ApiResponse<IEnumerable<AccessRequestDto>>.Ok(result));
}
/// <summary>
/// EN: Submit access request for approval.
/// VI: Gửi yêu cầu truy cập để phê duyệt.
/// </summary>
[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<IActionResult> SubmitAccessRequest(
[FromRoute] Guid id,
CancellationToken cancellationToken = default)
{
try
{
await _mediator.Send(new SubmitAccessRequestCommand(id), cancellationToken);
return Ok(ApiResponse<object>.Ok(new { Message = "Access request submitted successfully." }));
}
catch (Exception ex) when (ex.Message.Contains("not found"))
{
return NotFound(ApiResponse<object>.Fail("REQUEST_NOT_FOUND", ex.Message));
}
}
/// <summary>
/// EN: Approve access request.
/// VI: Phê duyệt yêu cầu truy cập.
/// </summary>
[HttpPost("{id:guid}/approve")]
[SwaggerOperation(Summary = "Approve access request", OperationId = "ApproveAccessRequest")]
[SwaggerResponse(StatusCodes.Status200OK, "Request approved")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid operation")]
public async Task<IActionResult> 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<object>.Ok(new { Message = "Access request approved." }));
}
catch (Exception ex) when (ex.Message.Contains("not found"))
{
return NotFound(ApiResponse<object>.Fail("REQUEST_NOT_FOUND", ex.Message));
}
catch (Exception ex)
{
return BadRequest(ApiResponse<object>.Fail("INVALID_OPERATION", ex.Message));
}
}
/// <summary>
/// EN: Reject access request.
/// VI: Từ chối yêu cầu truy cập.
/// </summary>
[HttpPost("{id:guid}/reject")]
[SwaggerOperation(Summary = "Reject access request", OperationId = "RejectAccessRequest")]
[SwaggerResponse(StatusCodes.Status200OK, "Request rejected")]
public async Task<IActionResult> 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<object>.Ok(new { Message = "Access request rejected." }));
}
catch (Exception ex) when (ex.Message.Contains("not found"))
{
return NotFound(ApiResponse<object>.Fail("REQUEST_NOT_FOUND", ex.Message));
}
catch (Exception ex)
{
return BadRequest(ApiResponse<object>.Fail("INVALID_OPERATION", ex.Message));
}
}
/// <summary>
/// EN: Cancel access request.
/// VI: Hủy yêu cầu truy cập.
/// </summary>
[HttpDelete("{id:guid}")]
[SwaggerOperation(Summary = "Cancel access request", OperationId = "CancelAccessRequest")]
[SwaggerResponse(StatusCodes.Status200OK, "Request cancelled")]
public async Task<IActionResult> CancelAccessRequest(
[FromRoute] Guid id,
CancellationToken cancellationToken = default)
{
try
{
await _mediator.Send(new CancelAccessRequestCommand(id), cancellationToken);
return Ok(ApiResponse<object>.Ok(new { Message = "Access request cancelled." }));
}
catch (Exception ex) when (ex.Message.Contains("not found"))
{
return NotFound(ApiResponse<object>.Fail("REQUEST_NOT_FOUND", ex.Message));
}
catch (Exception ex)
{
return BadRequest(ApiResponse<object>.Fail("INVALID_OPERATION", ex.Message));
}
}
}
#region Request/Response Models
public class CreateAccessRequestRequest
{
public Guid RequesterId { get; set; }
/// <example>Organization</example>
public string ResourceType { get; set; } = string.Empty;
public Guid ResourceId { get; set; }
/// <example>Admin</example>
public string RequestedPermission { get; set; } = string.Empty;
public string? Justification { get; set; }
/// <example>2</example>
public int? Priority { get; set; } // 1=Low, 2=Medium, 3=High, 4=Critical
public List<Guid> 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

View File

@@ -0,0 +1,206 @@
using IamService.Domain.Events;
using IamService.Domain.SeedWork;
namespace IamService.Domain.AggregatesModel.AccessRequestAggregate;
/// <summary>
/// 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.
/// </summary>
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<AccessRequestApprover> _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<AccessRequestApprover> 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));
}
/// <summary>
/// EN: Factory method to create access request.
/// VI: Factory method để tạo yêu cầu truy cập.
/// </summary>
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);
}
/// <summary>
/// EN: Add an approver to the approval chain.
/// VI: Thêm người phê duyệt vào chuỗi phê duyệt.
/// </summary>
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;
}
/// <summary>
/// EN: Submit request for approval.
/// VI: Gửi yêu cầu để phê duyệt.
/// </summary>
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()));
}
/// <summary>
/// EN: Approve the request by an approver.
/// VI: Phê duyệt yêu cầu bởi người phê duyệt.
/// </summary>
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));
}
}
/// <summary>
/// EN: Reject the request.
/// VI: Từ chối yêu cầu.
/// </summary>
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));
}
/// <summary>
/// EN: Cancel the request (by requester).
/// VI: Hủy yêu cầu (bởi người yêu cầu).
/// </summary>
public void Cancel()
{
if (_status.IsTerminal)
throw new InvalidOperationException("Cannot cancel a terminal request");
_status = AccessRequestStatus.Cancelled;
_resolvedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Mark request as expired.
/// VI: Đánh dấu yêu cầu đã hết hạn.
/// </summary>
public void Expire()
{
if (_status != AccessRequestStatus.Pending)
throw new InvalidOperationException("Only pending requests can expire");
_status = AccessRequestStatus.Expired;
_resolvedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update justification.
/// VI: Cập nhật lý do.
/// </summary>
public void UpdateJustification(string justification)
{
if (_status != AccessRequestStatus.Draft)
throw new InvalidOperationException("Cannot update submitted request");
_justification = justification;
}
}

View File

@@ -0,0 +1,94 @@
using IamService.Domain.SeedWork;
namespace IamService.Domain.AggregatesModel.AccessRequestAggregate;
/// <summary>
/// EN: Approver for an access request.
/// VI: Người phê duyệt cho yêu cầu truy cập.
/// </summary>
public class AccessRequestApprover : Entity
{
private Guid _requestId;
private Guid _userId;
private int _order;
private ApproverStatus _status;
private DateTime? _respondedAt;
private string? _comments;
/// <summary>
/// EN: Access request ID.
/// VI: ID yêu cầu truy cập.
/// </summary>
public Guid RequestId => _requestId;
/// <summary>
/// EN: Approver user ID.
/// VI: ID user phê duyệt.
/// </summary>
public Guid UserId => _userId;
/// <summary>
/// EN: Order in approval chain (1 = first).
/// VI: Thứ tự trong chuỗi phê duyệt (1 = đầu tiên).
/// </summary>
public int Order => _order;
/// <summary>
/// EN: Approver status.
/// VI: Trạng thái phê duyệt.
/// </summary>
public ApproverStatus Status => _status;
/// <summary>
/// EN: When approver responded.
/// VI: Thời gian phản hồi.
/// </summary>
public DateTime? RespondedAt => _respondedAt;
/// <summary>
/// EN: Approver comments.
/// VI: Nhận xét của người phê duyệt.
/// </summary>
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;
}
}
/// <summary>
/// EN: Approver status enumeration.
/// VI: Enumeration trạng thái phê duyệt.
/// </summary>
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) { }
}

View File

@@ -0,0 +1,30 @@
using IamService.Domain.SeedWork;
namespace IamService.Domain.AggregatesModel.AccessRequestAggregate;
/// <summary>
/// EN: Access request priority enumeration.
/// VI: Enumeration mức độ ưu tiên yêu cầu truy cập.
/// </summary>
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<AccessRequestPriority> GetAll() => [Low, Medium, High, Critical];
public static AccessRequestPriority? FromId(int id) => id switch
{
1 => Low,
2 => Medium,
3 => High,
4 => Critical,
_ => null
};
}

View File

@@ -0,0 +1,73 @@
using IamService.Domain.SeedWork;
namespace IamService.Domain.AggregatesModel.AccessRequestAggregate;
/// <summary>
/// EN: Access request status enumeration.
/// VI: Enumeration trạng thái yêu cầu truy cập.
/// </summary>
public class AccessRequestStatus : Enumeration
{
/// <summary>
/// EN: Request is being drafted, not yet submitted.
/// VI: Request đang được soạn, chưa gửi.
/// </summary>
public static readonly AccessRequestStatus Draft = new(1, nameof(Draft));
/// <summary>
/// EN: Request submitted and waiting for approval.
/// VI: Request đã gửi và đang chờ phê duyệt.
/// </summary>
public static readonly AccessRequestStatus Pending = new(2, nameof(Pending));
/// <summary>
/// EN: Request approved by all approvers.
/// VI: Request được phê duyệt bởi tất cả approver.
/// </summary>
public static readonly AccessRequestStatus Approved = new(3, nameof(Approved));
/// <summary>
/// EN: Request rejected by an approver.
/// VI: Request bị từ chối bởi approver.
/// </summary>
public static readonly AccessRequestStatus Rejected = new(4, nameof(Rejected));
/// <summary>
/// EN: Request cancelled by requester.
/// VI: Request bị hủy bởi người yêu cầu.
/// </summary>
public static readonly AccessRequestStatus Cancelled = new(5, nameof(Cancelled));
/// <summary>
/// EN: Request expired due to timeout.
/// VI: Request hết hạn do quá thời gian.
/// </summary>
public static readonly AccessRequestStatus Expired = new(6, nameof(Expired));
public AccessRequestStatus(int id, string name) : base(id, name)
{
}
public static IEnumerable<AccessRequestStatus> 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;
}

View File

@@ -0,0 +1,24 @@
using IamService.Domain.SeedWork;
namespace IamService.Domain.AggregatesModel.AccessRequestAggregate;
/// <summary>
/// EN: Repository interface for AccessRequest aggregate.
/// VI: Interface repository cho AccessRequest aggregate.
/// </summary>
public interface IAccessRequestRepository : IRepository<AccessRequest>
{
AccessRequest Add(AccessRequest request);
void Update(AccessRequest request);
Task<AccessRequest?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<AccessRequest?> GetByIdWithApproversAsync(Guid id, CancellationToken cancellationToken = default);
Task<IEnumerable<AccessRequest>> GetByRequesterIdAsync(Guid requesterId, CancellationToken cancellationToken = default);
Task<IEnumerable<AccessRequest>> GetPendingByApproverIdAsync(Guid approverId, CancellationToken cancellationToken = default);
Task<IEnumerable<AccessRequest>> GetExpiredRequestsAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,57 @@
using IamService.Domain.SeedWork;
namespace IamService.Domain.Events;
/// <summary>
/// EN: Event when access request is created.
/// VI: Event khi yêu cầu truy cập được tạo.
/// </summary>
public record AccessRequestCreatedEvent(
Guid RequestId,
Guid RequesterId,
string ResourceType,
Guid ResourceId,
string RequestedPermission) : IDomainEvent
{
public DateTime OccurredOn { get; } = DateTime.UtcNow;
}
/// <summary>
/// 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.
/// </summary>
public record AccessRequestSubmittedEvent(
Guid RequestId,
Guid RequesterId,
IReadOnlyList<Guid> ApproverIds) : IDomainEvent
{
public DateTime OccurredOn { get; } = DateTime.UtcNow;
}
/// <summary>
/// EN: Event when access request is approved.
/// VI: Event khi yêu cầu truy cập được phê duyệt.
/// </summary>
public record AccessRequestApprovedEvent(
Guid RequestId,
Guid RequesterId,
string ResourceType,
Guid ResourceId,
string Permission) : IDomainEvent
{
public DateTime OccurredOn { get; } = DateTime.UtcNow;
}
/// <summary>
/// EN: Event when access request is rejected.
/// VI: Event khi yêu cầu truy cập bị từ chối.
/// </summary>
public record AccessRequestRejectedEvent(
Guid RequestId,
Guid RequesterId,
Guid RejectedByUserId,
string? Reason) : IDomainEvent
{
public DateTime OccurredOn { get; } = DateTime.UtcNow;
}

View File

@@ -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<IOrganizationRepository, OrganizationRepository>();
services.AddScoped<IGroupRepository, GroupRepository>();
services.AddScoped<IIdentityVerificationRepository, IdentityVerificationRepository>();
services.AddScoped<IAccessRequestRepository, AccessRequestRepository>();
services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<IamServiceContext>());
// EN: Configure Redis caching (skip in Testing environment)

View File

@@ -0,0 +1,138 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using IamService.Domain.AggregatesModel.AccessRequestAggregate;
namespace IamService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: Entity configuration for AccessRequest.
/// VI: Cấu hình entity cho AccessRequest.
/// </summary>
public class AccessRequestEntityConfiguration : IEntityTypeConfiguration<AccessRequest>
{
public void Configure(EntityTypeBuilder<AccessRequest> builder)
{
builder.ToTable("AccessRequests");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id)
.ValueGeneratedNever();
builder.Property<Guid>("_requesterId")
.HasColumnName("RequesterId")
.IsRequired();
builder.Property<string>("_resourceType")
.HasColumnName("ResourceType")
.HasMaxLength(100)
.IsRequired();
builder.Property<Guid>("_resourceId")
.HasColumnName("ResourceId")
.IsRequired();
builder.Property<string>("_requestedPermission")
.HasColumnName("RequestedPermission")
.HasMaxLength(100)
.IsRequired();
builder.Property<string?>("_justification")
.HasColumnName("Justification")
.HasMaxLength(2000);
builder.Property<DateTime>("_createdAt")
.HasColumnName("CreatedAt")
.IsRequired();
builder.Property<DateTime?>("_submittedAt")
.HasColumnName("SubmittedAt");
builder.Property<DateTime?>("_resolvedAt")
.HasColumnName("ResolvedAt");
builder.Property<DateTime?>("_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<AccessRequestStatus>("_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<AccessRequestPriority>("_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");
}
}
/// <summary>
/// EN: Entity configuration for AccessRequestApprover.
/// VI: Cấu hình entity cho AccessRequestApprover.
/// </summary>
public class AccessRequestApproverEntityConfiguration : IEntityTypeConfiguration<AccessRequestApprover>
{
public void Configure(EntityTypeBuilder<AccessRequestApprover> builder)
{
builder.ToTable("AccessRequestApprovers");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id)
.ValueGeneratedNever();
builder.Property<Guid>("_requestId")
.HasColumnName("RequestId")
.IsRequired();
builder.Property<Guid>("_userId")
.HasColumnName("UserId")
.IsRequired();
builder.Property<int>("_order")
.HasColumnName("ApprovalOrder")
.IsRequired();
builder.Property<DateTime?>("_respondedAt")
.HasColumnName("RespondedAt");
builder.Property<string?>("_comments")
.HasColumnName("Comments")
.HasMaxLength(1000);
// EN: Status with value conversion
// VI: Status với value conversion
builder.Property<ApproverStatus>("_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");
}
}

View File

@@ -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<ApplicationUser, ApplicationR
/// </summary>
public DbSet<ProfileAttributeType> ProfileAttributeTypes { get; set; } = null!;
/// <summary>
/// EN: Access requests table.
/// VI: Bảng yêu cầu truy cập.
/// </summary>
public DbSet<AccessRequest> AccessRequests { get; set; } = null!;
/// <summary>
/// EN: Access request approvers table.
/// VI: Bảng người phê duyệt yêu cầu truy cập.
/// </summary>
public DbSet<AccessRequestApprover> AccessRequestApprovers { get; set; } = null!;
/// <summary>
/// EN: Check if there's an active transaction.
/// VI: Kiểm tra xem có transaction đang hoạt động không.

View File

@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore;
using IamService.Domain.AggregatesModel.AccessRequestAggregate;
using IamService.Domain.SeedWork;
namespace IamService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for AccessRequest aggregate.
/// VI: Repository implementation cho AccessRequest aggregate.
/// </summary>
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<AccessRequest?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.AccessRequests
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
}
public async Task<AccessRequest?> GetByIdWithApproversAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.AccessRequests
.Include(x => x.Approvers)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
}
public async Task<IEnumerable<AccessRequest>> GetByRequesterIdAsync(
Guid requesterId,
CancellationToken cancellationToken = default)
{
return await _context.AccessRequests
.Include(x => x.Approvers)
.Where(x => EF.Property<Guid>(x, "_requesterId") == requesterId)
.OrderByDescending(x => EF.Property<DateTime>(x, "_createdAt"))
.ToListAsync(cancellationToken);
}
public async Task<IEnumerable<AccessRequest>> GetPendingByApproverIdAsync(
Guid approverId,
CancellationToken cancellationToken = default)
{
return await _context.AccessRequests
.Include(x => x.Approvers)
.Where(x => x.Approvers.Any(a =>
EF.Property<Guid>(a, "_userId") == approverId &&
EF.Property<ApproverStatus>(a, "_status") == ApproverStatus.Pending))
.Where(x => EF.Property<AccessRequestStatus>(x, "_status") == AccessRequestStatus.Pending)
.OrderByDescending(x => EF.Property<DateTime>(x, "_createdAt"))
.ToListAsync(cancellationToken);
}
public async Task<IEnumerable<AccessRequest>> GetExpiredRequestsAsync(CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
return await _context.AccessRequests
.Where(x => EF.Property<AccessRequestStatus>(x, "_status") == AccessRequestStatus.Pending)
.Where(x => EF.Property<DateTime?>(x, "_expiresAt") < now)
.ToListAsync(cancellationToken);
}
}