feat: Add Access Review and Privileged Access functionality to IAM Service

- Introduced new AccessReview and PrivilegedAccess entities in the DbContext to enhance access management capabilities.
- Updated Dependency Injection to include AccessReviewRepository and PrivilegedAccessRepository, improving service functionality for access reviews and privileged access management.
This commit is contained in:
Ho Ngoc Hai
2026-01-14 16:02:34 +07:00
parent c041f3f7b2
commit 8b7db56b79
22 changed files with 1136 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
using MediatR;
using IamService.Domain.AggregatesModel.AccessReviewAggregate;
using IamService.Domain.Exceptions;
namespace IamService.API.Application.Commands.AccessReviews;
public class CreateAccessReviewCommandHandler : IRequestHandler<CreateAccessReviewCommand, Guid>
{
private readonly IAccessReviewRepository _repository;
public CreateAccessReviewCommandHandler(IAccessReviewRepository repository) => _repository = repository;
public async Task<Guid> Handle(CreateAccessReviewCommand request, CancellationToken cancellationToken)
{
var review = AccessReview.Create(request.Name, request.Description, request.OwnerId, request.Scope, request.DueDate);
_repository.Add(review);
await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
return review.Id;
}
}
public class AddAccessReviewItemCommandHandler : IRequestHandler<AddAccessReviewItemCommand, Guid>
{
private readonly IAccessReviewRepository _repository;
public AddAccessReviewItemCommandHandler(IAccessReviewRepository repository) => _repository = repository;
public async Task<Guid> Handle(AddAccessReviewItemCommand request, CancellationToken cancellationToken)
{
var review = await _repository.GetByIdWithItemsAsync(request.ReviewId, cancellationToken)
?? throw new DomainException($"Access review {request.ReviewId} not found.");
var item = review.AddItem(request.UserId, request.ResourceType, request.ResourceId, request.Permission);
_repository.Update(review);
await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
return item.Id;
}
}
public class StartAccessReviewCommandHandler : IRequestHandler<StartAccessReviewCommand, Unit>
{
private readonly IAccessReviewRepository _repository;
public StartAccessReviewCommandHandler(IAccessReviewRepository repository) => _repository = repository;
public async Task<Unit> Handle(StartAccessReviewCommand request, CancellationToken cancellationToken)
{
var review = await _repository.GetByIdWithItemsAsync(request.ReviewId, cancellationToken)
?? throw new DomainException($"Access review {request.ReviewId} not found.");
review.Start();
_repository.Update(review);
await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
return Unit.Value;
}
}
public class ReviewItemCommandHandler : IRequestHandler<ReviewItemCommand, Unit>
{
private readonly IAccessReviewRepository _repository;
public ReviewItemCommandHandler(IAccessReviewRepository repository) => _repository = repository;
public async Task<Unit> Handle(ReviewItemCommand request, CancellationToken cancellationToken)
{
var review = await _repository.GetByIdWithItemsAsync(request.ReviewId, cancellationToken)
?? throw new DomainException($"Access review {request.ReviewId} not found.");
review.ReviewItem(request.ItemId, request.ReviewerUserId, request.Certify, request.Comments);
_repository.Update(review);
await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
return Unit.Value;
}
}
public class CompleteAccessReviewCommandHandler : IRequestHandler<CompleteAccessReviewCommand, Unit>
{
private readonly IAccessReviewRepository _repository;
public CompleteAccessReviewCommandHandler(IAccessReviewRepository repository) => _repository = repository;
public async Task<Unit> Handle(CompleteAccessReviewCommand request, CancellationToken cancellationToken)
{
var review = await _repository.GetByIdWithItemsAsync(request.ReviewId, cancellationToken)
?? throw new DomainException($"Access review {request.ReviewId} not found.");
review.Complete();
_repository.Update(review);
await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,48 @@
using MediatR;
namespace IamService.API.Application.Commands.AccessReviews;
/// <summary>
/// EN: Command to create access review.
/// VI: Command để tạo đánh giá truy cập.
/// </summary>
public record CreateAccessReviewCommand(
string Name,
string? Description,
Guid OwnerId,
string Scope,
DateTime DueDate) : IRequest<Guid>;
/// <summary>
/// EN: Command to add item to access review.
/// VI: Command để thêm item vào đánh giá truy cập.
/// </summary>
public record AddAccessReviewItemCommand(
Guid ReviewId,
Guid UserId,
string ResourceType,
Guid ResourceId,
string Permission) : IRequest<Guid>;
/// <summary>
/// EN: Command to start access review.
/// VI: Command để bắt đầu đánh giá truy cập.
/// </summary>
public record StartAccessReviewCommand(Guid ReviewId) : IRequest<Unit>;
/// <summary>
/// EN: Command to review an item (certify or revoke).
/// VI: Command để đánh giá một item (chứng nhận hoặc thu hồi).
/// </summary>
public record ReviewItemCommand(
Guid ReviewId,
Guid ItemId,
Guid ReviewerUserId,
bool Certify,
string? Comments) : IRequest<Unit>;
/// <summary>
/// EN: Command to complete access review.
/// VI: Command để hoàn thành đánh giá truy cập.
/// </summary>
public record CompleteAccessReviewCommand(Guid ReviewId) : IRequest<Unit>;

View File

@@ -0,0 +1,48 @@
using MediatR;
using IamService.Domain.AggregatesModel.PrivilegedAccessAggregate;
using IamService.Domain.Exceptions;
namespace IamService.API.Application.Commands.PrivilegedAccess;
public class RequestPrivilegedAccessCommandHandler : IRequestHandler<RequestPrivilegedAccessCommand, Guid>
{
private readonly IPrivilegedAccessRepository _repository;
public RequestPrivilegedAccessCommandHandler(IPrivilegedAccessRepository repository) => _repository = repository;
public async Task<Guid> Handle(RequestPrivilegedAccessCommand request, CancellationToken cancellationToken)
{
// Check if user already has active grant for this role
var hasActive = await _repository.HasActiveGrantAsync(request.UserId, request.RoleId, cancellationToken);
if (hasActive)
throw new DomainException($"User already has active privileged access for role {request.RoleId}");
var grant = PrivilegedAccessGrant.Create(
request.UserId,
request.RoleId,
request.ResourceScope,
request.Reason,
request.GrantedByUserId,
request.DurationMinutes);
_repository.Add(grant);
await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
return grant.Id;
}
}
public class RevokePrivilegedAccessCommandHandler : IRequestHandler<RevokePrivilegedAccessCommand, Unit>
{
private readonly IPrivilegedAccessRepository _repository;
public RevokePrivilegedAccessCommandHandler(IPrivilegedAccessRepository repository) => _repository = repository;
public async Task<Unit> Handle(RevokePrivilegedAccessCommand request, CancellationToken cancellationToken)
{
var grant = await _repository.GetByIdAsync(request.GrantId, cancellationToken)
?? throw new DomainException($"Privileged access grant {request.GrantId} not found.");
grant.Revoke(request.RevokedByUserId, request.Reason);
_repository.Update(grant);
await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,24 @@
using MediatR;
namespace IamService.API.Application.Commands.PrivilegedAccess;
/// <summary>
/// EN: Command to request privileged access (PAM).
/// VI: Command để yêu cầu truy cập đặc quyền (PAM).
/// </summary>
public record RequestPrivilegedAccessCommand(
Guid UserId,
Guid RoleId,
string ResourceScope,
string? Reason,
Guid GrantedByUserId,
int DurationMinutes = 60) : IRequest<Guid>;
/// <summary>
/// EN: Command to revoke privileged access.
/// VI: Command để thu hồi truy cập đặc quyền.
/// </summary>
public record RevokePrivilegedAccessCommand(
Guid GrantId,
Guid RevokedByUserId,
string? Reason) : IRequest<Unit>;

View File

@@ -0,0 +1,16 @@
using MediatR;
namespace IamService.API.Application.Queries.PrivilegedAccess;
public record GetActivePrivilegedAccessQuery(Guid UserId) : IRequest<IEnumerable<PrivilegedAccessDto>>;
public record PrivilegedAccessDto(
Guid Id,
Guid UserId,
Guid RoleId,
string ResourceScope,
string? Reason,
string Status,
DateTime StartsAt,
DateTime ExpiresAt,
Guid GrantedByUserId);

View File

@@ -0,0 +1,25 @@
using MediatR;
using IamService.Domain.AggregatesModel.PrivilegedAccessAggregate;
namespace IamService.API.Application.Queries.PrivilegedAccess;
public class GetActivePrivilegedAccessQueryHandler : IRequestHandler<GetActivePrivilegedAccessQuery, IEnumerable<PrivilegedAccessDto>>
{
private readonly IPrivilegedAccessRepository _repository;
public GetActivePrivilegedAccessQueryHandler(IPrivilegedAccessRepository repository) => _repository = repository;
public async Task<IEnumerable<PrivilegedAccessDto>> Handle(GetActivePrivilegedAccessQuery request, CancellationToken cancellationToken)
{
var grants = await _repository.GetActiveByUserIdAsync(request.UserId, cancellationToken);
return grants.Select(g => new PrivilegedAccessDto(
g.Id,
g.UserId,
g.RoleId,
g.ResourceScope,
g.Reason,
g.Status.Name,
g.StartsAt,
g.ExpiresAt,
g.GrantedByUserId));
}
}

View File

@@ -0,0 +1,90 @@
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.AccessReviews;
namespace IamService.API.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/access-reviews")]
[Authorize(AuthenticationSchemes = "Bearer")]
[SwaggerTag("Access review management - periodic access certification")]
public class AccessReviewsController : ControllerBase
{
private readonly IMediator _mediator;
public AccessReviewsController(IMediator mediator) => _mediator = mediator;
[HttpPost]
[SwaggerOperation(Summary = "Create access review", OperationId = "CreateAccessReview")]
public async Task<IActionResult> Create([FromBody] CreateReviewRequest request, CancellationToken ct = default)
{
var id = await _mediator.Send(new CreateAccessReviewCommand(request.Name, request.Description, request.OwnerId, request.Scope, request.DueDate), ct);
return CreatedAtAction(nameof(GetById), new { id }, ApiResponse<object>.Ok(new { Id = id }));
}
[HttpGet("{id:guid}")]
[SwaggerOperation(Summary = "Get access review by ID", OperationId = "GetAccessReviewById")]
public async Task<IActionResult> GetById([FromRoute] Guid id, CancellationToken ct = default)
{
// Simplified - just return success for now
return Ok(ApiResponse<object>.Ok(new { Id = id, Message = "Review found" }));
}
[HttpPost("{id:guid}/items")]
[SwaggerOperation(Summary = "Add item to access review", OperationId = "AddAccessReviewItem")]
public async Task<IActionResult> AddItem([FromRoute] Guid id, [FromBody] AddItemRequest request, CancellationToken ct = default)
{
try
{
var itemId = await _mediator.Send(new AddAccessReviewItemCommand(id, request.UserId, request.ResourceType, request.ResourceId, request.Permission), ct);
return Ok(ApiResponse<object>.Ok(new { ItemId = itemId }));
}
catch (Exception ex) { return BadRequest(ApiResponse<object>.Fail("ERROR", ex.Message)); }
}
[HttpPost("{id:guid}/start")]
[SwaggerOperation(Summary = "Start access review", OperationId = "StartAccessReview")]
public async Task<IActionResult> Start([FromRoute] Guid id, CancellationToken ct = default)
{
try
{
await _mediator.Send(new StartAccessReviewCommand(id), ct);
return Ok(ApiResponse<object>.Ok(new { Message = "Review started" }));
}
catch (Exception ex) { return BadRequest(ApiResponse<object>.Fail("ERROR", ex.Message)); }
}
[HttpPost("{id:guid}/items/{itemId:guid}/review")]
[SwaggerOperation(Summary = "Review an item", OperationId = "ReviewItem")]
public async Task<IActionResult> ReviewItem([FromRoute] Guid id, [FromRoute] Guid itemId, [FromBody] ReviewItemRequest request, CancellationToken ct = default)
{
try
{
await _mediator.Send(new ReviewItemCommand(id, itemId, request.ReviewerUserId, request.Certify, request.Comments), ct);
return Ok(ApiResponse<object>.Ok(new { Message = request.Certify ? "Item certified" : "Item revoked" }));
}
catch (Exception ex) { return BadRequest(ApiResponse<object>.Fail("ERROR", ex.Message)); }
}
[HttpPost("{id:guid}/complete")]
[SwaggerOperation(Summary = "Complete access review", OperationId = "CompleteAccessReview")]
public async Task<IActionResult> Complete([FromRoute] Guid id, CancellationToken ct = default)
{
try
{
await _mediator.Send(new CompleteAccessReviewCommand(id), ct);
return Ok(ApiResponse<object>.Ok(new { Message = "Review completed" }));
}
catch (Exception ex) { return BadRequest(ApiResponse<object>.Fail("ERROR", ex.Message)); }
}
}
#region Request Models
public record CreateReviewRequest(string Name, string? Description, Guid OwnerId, string Scope, DateTime DueDate);
public record AddItemRequest(Guid UserId, string ResourceType, Guid ResourceId, string Permission);
public record ReviewItemRequest(Guid ReviewerUserId, bool Certify, string? Comments);
#endregion

View File

@@ -0,0 +1,60 @@
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.PrivilegedAccess;
using IamService.API.Application.Queries.PrivilegedAccess;
namespace IamService.API.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/privileged-access")]
[Authorize(AuthenticationSchemes = "Bearer")]
[SwaggerTag("Privileged Access Management (PAM) - Just-In-Time elevated access")]
public class PrivilegedAccessController : ControllerBase
{
private readonly IMediator _mediator;
public PrivilegedAccessController(IMediator mediator) => _mediator = mediator;
[HttpPost("request")]
[SwaggerOperation(Summary = "Request privileged access", OperationId = "RequestPrivilegedAccess")]
public async Task<IActionResult> RequestAccess([FromBody] RequestPamRequest request, CancellationToken ct = default)
{
try
{
var id = await _mediator.Send(new RequestPrivilegedAccessCommand(
request.UserId, request.RoleId, request.ResourceScope,
request.Reason, request.GrantedByUserId, request.DurationMinutes), ct);
return Ok(ApiResponse<object>.Ok(new { GrantId = id, ExpiresInMinutes = request.DurationMinutes }));
}
catch (Exception ex) { return BadRequest(ApiResponse<object>.Fail("ERROR", ex.Message)); }
}
[HttpGet("active")]
[SwaggerOperation(Summary = "Get active privileged grants", OperationId = "GetActivePrivilegedAccess")]
public async Task<IActionResult> GetActive([FromQuery] Guid userId, CancellationToken ct = default)
{
var grants = await _mediator.Send(new GetActivePrivilegedAccessQuery(userId), ct);
return Ok(ApiResponse<IEnumerable<PrivilegedAccessDto>>.Ok(grants));
}
[HttpPost("{id:guid}/revoke")]
[SwaggerOperation(Summary = "Revoke privileged access", OperationId = "RevokePrivilegedAccess")]
public async Task<IActionResult> Revoke([FromRoute] Guid id, [FromBody] RevokePamRequest request, CancellationToken ct = default)
{
try
{
await _mediator.Send(new RevokePrivilegedAccessCommand(id, request.RevokedByUserId, request.Reason), ct);
return Ok(ApiResponse<object>.Ok(new { Message = "Privileged access revoked" }));
}
catch (Exception ex) { return BadRequest(ApiResponse<object>.Fail("ERROR", ex.Message)); }
}
}
#region Request Models
public record RequestPamRequest(Guid UserId, Guid RoleId, string ResourceScope, string? Reason, Guid GrantedByUserId, int DurationMinutes = 60);
public record RevokePamRequest(Guid RevokedByUserId, string? Reason);
#endregion

View File

@@ -0,0 +1,127 @@
using IamService.Domain.Events;
using IamService.Domain.SeedWork;
namespace IamService.Domain.AggregatesModel.AccessReviewAggregate;
/// <summary>
/// EN: Access review aggregate root for periodic access certification.
/// VI: Aggregate root đánh giá truy cập cho chứng nhận truy cập định kỳ.
/// </summary>
public class AccessReview : Entity, IAggregateRoot
{
private string _name = null!;
private string? _description;
private Guid _ownerId;
private string _scope = null!; // e.g., "Organization:xxx" or "Role:yyy"
private AccessReviewStatus _status = null!;
private DateTime _createdAt;
private DateTime? _startedAt;
private DateTime _dueDate;
private DateTime? _completedAt;
private readonly List<AccessReviewItem> _items = [];
#region Properties
public string Name => _name;
public string? Description => _description;
public Guid OwnerId => _ownerId;
public string Scope => _scope;
public AccessReviewStatus Status => _status;
public DateTime CreatedAt => _createdAt;
public DateTime? StartedAt => _startedAt;
public DateTime DueDate => _dueDate;
public DateTime? CompletedAt => _completedAt;
public IReadOnlyCollection<AccessReviewItem> Items => _items.AsReadOnly();
#endregion
protected AccessReview() { }
private AccessReview(string name, string? description, Guid ownerId, string scope, DateTime dueDate)
{
Id = Guid.NewGuid();
_name = name;
_description = description;
_ownerId = ownerId;
_scope = scope;
_status = AccessReviewStatus.Draft;
_createdAt = DateTime.UtcNow;
_dueDate = dueDate;
AddDomainEvent(new AccessReviewCreatedEvent(Id, name, ownerId, scope));
}
public static AccessReview Create(string name, string? description, Guid ownerId, string scope, DateTime dueDate)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Name cannot be empty", nameof(name));
if (ownerId == Guid.Empty)
throw new ArgumentException("Owner ID cannot be empty", nameof(ownerId));
if (dueDate <= DateTime.UtcNow)
throw new ArgumentException("Due date must be in the future", nameof(dueDate));
return new AccessReview(name, description, ownerId, scope, dueDate);
}
public AccessReviewItem AddItem(Guid userId, string resourceType, Guid resourceId, string permission)
{
if (_status != AccessReviewStatus.Draft)
throw new InvalidOperationException("Can only add items to draft reviews");
var item = new AccessReviewItem(Id, userId, resourceType, resourceId, permission);
_items.Add(item);
return item;
}
public void Start()
{
if (_status != AccessReviewStatus.Draft)
throw new InvalidOperationException("Only draft reviews can be started");
if (_items.Count == 0)
throw new InvalidOperationException("Review must have at least one item");
_status = AccessReviewStatus.Active;
_startedAt = DateTime.UtcNow;
AddDomainEvent(new AccessReviewStartedEvent(Id, _ownerId, _items.Count));
}
public void ReviewItem(Guid itemId, Guid reviewerUserId, bool certify, string? comments = null)
{
if (_status != AccessReviewStatus.Active)
throw new InvalidOperationException("Can only review items in active reviews");
var item = _items.FirstOrDefault(i => i.Id == itemId)
?? throw new InvalidOperationException("Item not found");
if (certify)
item.Certify(reviewerUserId, comments);
else
item.Revoke(reviewerUserId, comments);
}
public void Complete()
{
if (_status != AccessReviewStatus.Active)
throw new InvalidOperationException("Only active reviews can be completed");
var pendingItems = _items.Count(i => i.Decision == ReviewDecision.Pending);
if (pendingItems > 0)
throw new InvalidOperationException($"Cannot complete review with {pendingItems} pending items");
_status = AccessReviewStatus.Completed;
_completedAt = DateTime.UtcNow;
var certifiedCount = _items.Count(i => i.Decision == ReviewDecision.Certify);
var revokedCount = _items.Count(i => i.Decision == ReviewDecision.Revoke);
AddDomainEvent(new AccessReviewCompletedEvent(Id, certifiedCount, revokedCount));
}
public void Cancel()
{
if (_status == AccessReviewStatus.Completed)
throw new InvalidOperationException("Cannot cancel completed review");
_status = AccessReviewStatus.Cancelled;
}
}

View File

@@ -0,0 +1,67 @@
using IamService.Domain.SeedWork;
namespace IamService.Domain.AggregatesModel.AccessReviewAggregate;
/// <summary>
/// EN: Access review item entity - represents a single access to be reviewed.
/// VI: Entity item đánh giá truy cập - đại diện cho một quyền truy cập cần đánh giá.
/// </summary>
public class AccessReviewItem : Entity
{
private Guid _reviewId;
private Guid _userId;
private string _resourceType = null!;
private Guid _resourceId;
private string _permission = null!;
private ReviewDecision _decision;
private Guid? _reviewedByUserId;
private DateTime? _reviewedAt;
private string? _comments;
public Guid ReviewId => _reviewId;
public Guid UserId => _userId;
public string ResourceType => _resourceType;
public Guid ResourceId => _resourceId;
public string Permission => _permission;
public ReviewDecision Decision => _decision;
public Guid? ReviewedByUserId => _reviewedByUserId;
public DateTime? ReviewedAt => _reviewedAt;
public string? Comments => _comments;
protected AccessReviewItem()
{
_decision = ReviewDecision.Pending;
}
public AccessReviewItem(
Guid reviewId,
Guid userId,
string resourceType,
Guid resourceId,
string permission)
{
Id = Guid.NewGuid();
_reviewId = reviewId;
_userId = userId;
_resourceType = resourceType;
_resourceId = resourceId;
_permission = permission;
_decision = ReviewDecision.Pending;
}
public void Certify(Guid reviewerUserId, string? comments = null)
{
_decision = ReviewDecision.Certify;
_reviewedByUserId = reviewerUserId;
_reviewedAt = DateTime.UtcNow;
_comments = comments;
}
public void Revoke(Guid reviewerUserId, string? comments = null)
{
_decision = ReviewDecision.Revoke;
_reviewedByUserId = reviewerUserId;
_reviewedAt = DateTime.UtcNow;
_comments = comments;
}
}

View File

@@ -0,0 +1,47 @@
using IamService.Domain.SeedWork;
namespace IamService.Domain.AggregatesModel.AccessReviewAggregate;
/// <summary>
/// EN: Access review status enumeration.
/// VI: Enumeration trạng thái đánh giá truy cập.
/// </summary>
public class AccessReviewStatus : Enumeration
{
public static readonly AccessReviewStatus Draft = new(1, nameof(Draft));
public static readonly AccessReviewStatus Active = new(2, nameof(Active));
public static readonly AccessReviewStatus Completed = new(3, nameof(Completed));
public static readonly AccessReviewStatus Cancelled = new(4, nameof(Cancelled));
public AccessReviewStatus(int id, string name) : base(id, name) { }
public static AccessReviewStatus? FromId(int id) => id switch
{
1 => Draft,
2 => Active,
3 => Completed,
4 => Cancelled,
_ => null
};
}
/// <summary>
/// EN: Review decision enumeration.
/// VI: Enumeration quyết định đánh giá.
/// </summary>
public class ReviewDecision : Enumeration
{
public static readonly ReviewDecision Pending = new(1, nameof(Pending));
public static readonly ReviewDecision Certify = new(2, nameof(Certify));
public static readonly ReviewDecision Revoke = new(3, nameof(Revoke));
public ReviewDecision(int id, string name) : base(id, name) { }
public static ReviewDecision? FromId(int id) => id switch
{
1 => Pending,
2 => Certify,
3 => Revoke,
_ => null
};
}

View File

@@ -0,0 +1,17 @@
using IamService.Domain.SeedWork;
namespace IamService.Domain.AggregatesModel.AccessReviewAggregate;
/// <summary>
/// EN: Repository interface for AccessReview aggregate.
/// VI: Interface repository cho AccessReview aggregate.
/// </summary>
public interface IAccessReviewRepository : IRepository<AccessReview>
{
AccessReview Add(AccessReview review);
void Update(AccessReview review);
Task<AccessReview?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<AccessReview?> GetByIdWithItemsAsync(Guid id, CancellationToken cancellationToken = default);
Task<IEnumerable<AccessReview>> GetByOwnerIdAsync(Guid ownerId, CancellationToken cancellationToken = default);
Task<IEnumerable<AccessReview>> GetActiveReviewsAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,17 @@
using IamService.Domain.SeedWork;
namespace IamService.Domain.AggregatesModel.PrivilegedAccessAggregate;
/// <summary>
/// EN: Repository interface for PrivilegedAccessGrant aggregate.
/// VI: Interface repository cho PrivilegedAccessGrant aggregate.
/// </summary>
public interface IPrivilegedAccessRepository : IRepository<PrivilegedAccessGrant>
{
PrivilegedAccessGrant Add(PrivilegedAccessGrant grant);
void Update(PrivilegedAccessGrant grant);
Task<PrivilegedAccessGrant?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<IEnumerable<PrivilegedAccessGrant>> GetActiveByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
Task<IEnumerable<PrivilegedAccessGrant>> GetExpiredGrantsAsync(CancellationToken cancellationToken = default);
Task<bool> HasActiveGrantAsync(Guid userId, Guid roleId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,144 @@
using IamService.Domain.Events;
using IamService.Domain.SeedWork;
namespace IamService.Domain.AggregatesModel.PrivilegedAccessAggregate;
/// <summary>
/// EN: Privileged access grant aggregate for Just-In-Time (JIT) elevated access.
/// VI: Aggregate cấp quyền truy cập đặc quyền cho truy cập nâng cao tức thời (JIT).
/// </summary>
public class PrivilegedAccessGrant : Entity, IAggregateRoot
{
private Guid _userId;
private Guid _roleId;
private string _resourceScope = null!; // e.g., "Organization:xxx" or "*"
private string? _reason;
private Guid _grantedByUserId;
private PrivilegedAccessStatus _status = null!;
private DateTime _createdAt;
private DateTime _startsAt;
private DateTime _expiresAt;
private DateTime? _revokedAt;
private Guid? _revokedByUserId;
private string? _revocationReason;
#region Properties
public Guid UserId => _userId;
public Guid RoleId => _roleId;
public string ResourceScope => _resourceScope;
public string? Reason => _reason;
public Guid GrantedByUserId => _grantedByUserId;
public PrivilegedAccessStatus Status => _status;
public DateTime CreatedAt => _createdAt;
public DateTime StartsAt => _startsAt;
public DateTime ExpiresAt => _expiresAt;
public DateTime? RevokedAt => _revokedAt;
public Guid? RevokedByUserId => _revokedByUserId;
public string? RevocationReason => _revocationReason;
public bool IsActive => _status == PrivilegedAccessStatus.Active && DateTime.UtcNow >= _startsAt && DateTime.UtcNow < _expiresAt;
#endregion
protected PrivilegedAccessGrant() { }
private PrivilegedAccessGrant(
Guid userId,
Guid roleId,
string resourceScope,
string? reason,
Guid grantedByUserId,
DateTime startsAt,
DateTime expiresAt)
{
Id = Guid.NewGuid();
_userId = userId;
_roleId = roleId;
_resourceScope = resourceScope;
_reason = reason;
_grantedByUserId = grantedByUserId;
_status = startsAt <= DateTime.UtcNow ? PrivilegedAccessStatus.Active : PrivilegedAccessStatus.Pending;
_createdAt = DateTime.UtcNow;
_startsAt = startsAt;
_expiresAt = expiresAt;
AddDomainEvent(new PrivilegedAccessGrantedEvent(Id, userId, roleId, resourceScope, expiresAt));
}
public static PrivilegedAccessGrant Create(
Guid userId,
Guid roleId,
string resourceScope,
string? reason,
Guid grantedByUserId,
int durationMinutes = 60)
{
if (userId == Guid.Empty)
throw new ArgumentException("User ID cannot be empty", nameof(userId));
if (roleId == Guid.Empty)
throw new ArgumentException("Role ID cannot be empty", nameof(roleId));
if (string.IsNullOrWhiteSpace(resourceScope))
throw new ArgumentException("Resource scope cannot be empty", nameof(resourceScope));
if (durationMinutes < 5 || durationMinutes > 480)
throw new ArgumentException("Duration must be between 5 and 480 minutes", nameof(durationMinutes));
var startsAt = DateTime.UtcNow;
var expiresAt = startsAt.AddMinutes(durationMinutes);
return new PrivilegedAccessGrant(userId, roleId, resourceScope, reason, grantedByUserId, startsAt, expiresAt);
}
public static PrivilegedAccessGrant CreateScheduled(
Guid userId,
Guid roleId,
string resourceScope,
string? reason,
Guid grantedByUserId,
DateTime startsAt,
DateTime expiresAt)
{
if (startsAt >= expiresAt)
throw new ArgumentException("Start time must be before expiration time");
if (expiresAt <= DateTime.UtcNow)
throw new ArgumentException("Expiration time must be in the future");
return new PrivilegedAccessGrant(userId, roleId, resourceScope, reason, grantedByUserId, startsAt, expiresAt);
}
public void Activate()
{
if (_status != PrivilegedAccessStatus.Pending)
throw new InvalidOperationException("Only pending grants can be activated");
_status = PrivilegedAccessStatus.Active;
}
public void Revoke(Guid revokedByUserId, string? reason = null)
{
if (_status.IsTerminal)
throw new InvalidOperationException("Grant is already terminated");
_status = PrivilegedAccessStatus.Revoked;
_revokedAt = DateTime.UtcNow;
_revokedByUserId = revokedByUserId;
_revocationReason = reason;
AddDomainEvent(new PrivilegedAccessRevokedEvent(Id, _userId, revokedByUserId, reason));
}
public void Expire()
{
if (_status.IsTerminal)
throw new InvalidOperationException("Grant is already terminated");
_status = PrivilegedAccessStatus.Expired;
}
public void Extend(int additionalMinutes, Guid extendedByUserId)
{
if (_status.IsTerminal)
throw new InvalidOperationException("Cannot extend terminated grant");
if (additionalMinutes < 5 || additionalMinutes > 240)
throw new ArgumentException("Extension must be between 5 and 240 minutes");
_expiresAt = _expiresAt.AddMinutes(additionalMinutes);
}
}

View File

@@ -0,0 +1,28 @@
using IamService.Domain.SeedWork;
namespace IamService.Domain.AggregatesModel.PrivilegedAccessAggregate;
/// <summary>
/// EN: Privileged access status enumeration.
/// VI: Enumeration trạng thái truy cập đặc quyền.
/// </summary>
public class PrivilegedAccessStatus : Enumeration
{
public static readonly PrivilegedAccessStatus Pending = new(1, nameof(Pending));
public static readonly PrivilegedAccessStatus Active = new(2, nameof(Active));
public static readonly PrivilegedAccessStatus Expired = new(3, nameof(Expired));
public static readonly PrivilegedAccessStatus Revoked = new(4, nameof(Revoked));
public PrivilegedAccessStatus(int id, string name) : base(id, name) { }
public static PrivilegedAccessStatus? FromId(int id) => id switch
{
1 => Pending,
2 => Active,
3 => Expired,
4 => Revoked,
_ => null
};
public bool IsTerminal => this == Expired || this == Revoked;
}

View File

@@ -0,0 +1,67 @@
using IamService.Domain.SeedWork;
namespace IamService.Domain.Events;
/// <summary>
/// EN: Event when access review is created.
/// VI: Event khi đánh giá truy cập được tạo.
/// </summary>
public record AccessReviewCreatedEvent(
Guid ReviewId,
string Name,
Guid OwnerId,
string Scope) : IDomainEvent
{
public DateTime OccurredOn { get; } = DateTime.UtcNow;
}
/// <summary>
/// EN: Event when access review is started.
/// VI: Event khi đánh giá truy cập được bắt đầu.
/// </summary>
public record AccessReviewStartedEvent(
Guid ReviewId,
Guid OwnerId,
int ItemCount) : IDomainEvent
{
public DateTime OccurredOn { get; } = DateTime.UtcNow;
}
/// <summary>
/// EN: Event when access review is completed.
/// VI: Event khi đánh giá truy cập hoàn thành.
/// </summary>
public record AccessReviewCompletedEvent(
Guid ReviewId,
int CertifiedCount,
int RevokedCount) : IDomainEvent
{
public DateTime OccurredOn { get; } = DateTime.UtcNow;
}
/// <summary>
/// EN: Event when privileged access is granted.
/// VI: Event khi quyền truy cập đặc quyền được cấp.
/// </summary>
public record PrivilegedAccessGrantedEvent(
Guid GrantId,
Guid UserId,
Guid RoleId,
string ResourceScope,
DateTime ExpiresAt) : IDomainEvent
{
public DateTime OccurredOn { get; } = DateTime.UtcNow;
}
/// <summary>
/// EN: Event when privileged access is revoked.
/// VI: Event khi quyền truy cập đặc quyền bị thu hồi.
/// </summary>
public record PrivilegedAccessRevokedEvent(
Guid GrantId,
Guid UserId,
Guid RevokedByUserId,
string? Reason) : IDomainEvent
{
public DateTime OccurredOn { get; } = DateTime.UtcNow;
}

View File

@@ -11,6 +11,8 @@ using IamService.Domain.AggregatesModel.OrganizationAggregate;
using IamService.Domain.AggregatesModel.GroupAggregate;
using IamService.Domain.AggregatesModel.VerificationAggregate;
using IamService.Domain.AggregatesModel.AccessRequestAggregate;
using IamService.Domain.AggregatesModel.AccessReviewAggregate;
using IamService.Domain.AggregatesModel.PrivilegedAccessAggregate;
using IamService.Domain.SeedWork;
using IamService.Infrastructure.Email;
using IamService.Infrastructure.IdentityServer;
@@ -155,6 +157,8 @@ public static class DependencyInjection
services.AddScoped<IGroupRepository, GroupRepository>();
services.AddScoped<IIdentityVerificationRepository, IdentityVerificationRepository>();
services.AddScoped<IAccessRequestRepository, AccessRequestRepository>();
services.AddScoped<IAccessReviewRepository, AccessReviewRepository>();
services.AddScoped<IPrivilegedAccessRepository, PrivilegedAccessRepository>();
services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<IamServiceContext>());
// EN: Configure Redis caching (skip in Testing environment)

View File

@@ -0,0 +1,70 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using IamService.Domain.AggregatesModel.AccessReviewAggregate;
namespace IamService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: Entity configuration for AccessReview.
/// VI: Cấu hình entity cho AccessReview.
/// </summary>
public class AccessReviewEntityConfiguration : IEntityTypeConfiguration<AccessReview>
{
public void Configure(EntityTypeBuilder<AccessReview> builder)
{
builder.ToTable("AccessReviews");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).ValueGeneratedNever();
builder.Property<string>("_name").HasColumnName("Name").HasMaxLength(200).IsRequired();
builder.Property<string?>("_description").HasColumnName("Description").HasMaxLength(1000);
builder.Property<Guid>("_ownerId").HasColumnName("OwnerId").IsRequired();
builder.Property<string>("_scope").HasColumnName("Scope").HasMaxLength(200).IsRequired();
builder.Property<DateTime>("_createdAt").HasColumnName("CreatedAt").IsRequired();
builder.Property<DateTime?>("_startedAt").HasColumnName("StartedAt");
builder.Property<DateTime>("_dueDate").HasColumnName("DueDate").IsRequired();
builder.Property<DateTime?>("_completedAt").HasColumnName("CompletedAt");
builder.Property<AccessReviewStatus>("_status")
.HasColumnName("StatusId")
.HasConversion(v => v.Id, v => AccessReviewStatus.FromId(v) ?? AccessReviewStatus.Draft);
builder.HasMany(x => x.Items)
.WithOne()
.HasForeignKey("_reviewId")
.OnDelete(DeleteBehavior.Cascade);
builder.Ignore(x => x.DomainEvents);
builder.HasIndex("_ownerId").HasDatabaseName("IX_AccessReviews_OwnerId");
}
}
/// <summary>
/// EN: Entity configuration for AccessReviewItem.
/// VI: Cấu hình entity cho AccessReviewItem.
/// </summary>
public class AccessReviewItemEntityConfiguration : IEntityTypeConfiguration<AccessReviewItem>
{
public void Configure(EntityTypeBuilder<AccessReviewItem> builder)
{
builder.ToTable("AccessReviewItems");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).ValueGeneratedNever();
builder.Property<Guid>("_reviewId").HasColumnName("ReviewId").IsRequired();
builder.Property<Guid>("_userId").HasColumnName("UserId").IsRequired();
builder.Property<string>("_resourceType").HasColumnName("ResourceType").HasMaxLength(100).IsRequired();
builder.Property<Guid>("_resourceId").HasColumnName("ResourceId").IsRequired();
builder.Property<string>("_permission").HasColumnName("Permission").HasMaxLength(100).IsRequired();
builder.Property<Guid?>("_reviewedByUserId").HasColumnName("ReviewedByUserId");
builder.Property<DateTime?>("_reviewedAt").HasColumnName("ReviewedAt");
builder.Property<string?>("_comments").HasColumnName("Comments").HasMaxLength(500);
builder.Property<ReviewDecision>("_decision")
.HasColumnName("DecisionId")
.HasConversion(v => v.Id, v => ReviewDecision.FromId(v) ?? ReviewDecision.Pending);
builder.Ignore(x => x.DomainEvents);
builder.HasIndex("_userId").HasDatabaseName("IX_AccessReviewItems_UserId");
}
}

View File

@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using IamService.Domain.AggregatesModel.PrivilegedAccessAggregate;
namespace IamService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: Entity configuration for PrivilegedAccessGrant.
/// VI: Cấu hình entity cho PrivilegedAccessGrant.
/// </summary>
public class PrivilegedAccessEntityConfiguration : IEntityTypeConfiguration<PrivilegedAccessGrant>
{
public void Configure(EntityTypeBuilder<PrivilegedAccessGrant> builder)
{
builder.ToTable("PrivilegedAccessGrants");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).ValueGeneratedNever();
builder.Property<Guid>("_userId").HasColumnName("UserId").IsRequired();
builder.Property<Guid>("_roleId").HasColumnName("RoleId").IsRequired();
builder.Property<string>("_resourceScope").HasColumnName("ResourceScope").HasMaxLength(200).IsRequired();
builder.Property<string?>("_reason").HasColumnName("Reason").HasMaxLength(500);
builder.Property<Guid>("_grantedByUserId").HasColumnName("GrantedByUserId").IsRequired();
builder.Property<DateTime>("_createdAt").HasColumnName("CreatedAt").IsRequired();
builder.Property<DateTime>("_startsAt").HasColumnName("StartsAt").IsRequired();
builder.Property<DateTime>("_expiresAt").HasColumnName("ExpiresAt").IsRequired();
builder.Property<DateTime?>("_revokedAt").HasColumnName("RevokedAt");
builder.Property<Guid?>("_revokedByUserId").HasColumnName("RevokedByUserId");
builder.Property<string?>("_revocationReason").HasColumnName("RevocationReason").HasMaxLength(500);
builder.Property<PrivilegedAccessStatus>("_status")
.HasColumnName("StatusId")
.HasConversion(v => v.Id, v => PrivilegedAccessStatus.FromId(v) ?? PrivilegedAccessStatus.Pending);
builder.Ignore(x => x.DomainEvents);
builder.HasIndex("_userId").HasDatabaseName("IX_PrivilegedAccessGrants_UserId");
builder.HasIndex("_userId", "_roleId", "_status").HasDatabaseName("IX_PrivilegedAccessGrants_Active");
}
}

View File

@@ -9,6 +9,8 @@ using IamService.Domain.AggregatesModel.OrganizationAggregate;
using IamService.Domain.AggregatesModel.GroupAggregate;
using IamService.Domain.AggregatesModel.VerificationAggregate;
using IamService.Domain.AggregatesModel.AccessRequestAggregate;
using IamService.Domain.AggregatesModel.AccessReviewAggregate;
using IamService.Domain.AggregatesModel.PrivilegedAccessAggregate;
using IamService.Domain.SeedWork;
namespace IamService.Infrastructure;
@@ -125,6 +127,24 @@ public class IamServiceContext : IdentityDbContext<ApplicationUser, ApplicationR
/// </summary>
public DbSet<AccessRequestApprover> AccessRequestApprovers { get; set; } = null!;
/// <summary>
/// EN: Access reviews table.
/// VI: Bảng đánh giá truy cập.
/// </summary>
public DbSet<AccessReview> AccessReviews { get; set; } = null!;
/// <summary>
/// EN: Access review items table.
/// VI: Bảng items đánh giá truy cập.
/// </summary>
public DbSet<AccessReviewItem> AccessReviewItems { get; set; } = null!;
/// <summary>
/// EN: Privileged access grants table.
/// VI: Bảng cấp quyền truy cập đặc quyền.
/// </summary>
public DbSet<PrivilegedAccessGrant> PrivilegedAccessGrants { 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,38 @@
using Microsoft.EntityFrameworkCore;
using IamService.Domain.AggregatesModel.AccessReviewAggregate;
using IamService.Domain.SeedWork;
namespace IamService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for AccessReview aggregate.
/// VI: Repository implementation cho AccessReview aggregate.
/// </summary>
public class AccessReviewRepository : IAccessReviewRepository
{
private readonly IamServiceContext _context;
public IUnitOfWork UnitOfWork => _context;
public AccessReviewRepository(IamServiceContext context) => _context = context;
public AccessReview Add(AccessReview review) => _context.AccessReviews.Add(review).Entity;
public void Update(AccessReview review) => _context.Entry(review).State = EntityState.Modified;
public async Task<AccessReview?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
=> await _context.AccessReviews.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
public async Task<AccessReview?> GetByIdWithItemsAsync(Guid id, CancellationToken cancellationToken = default)
=> await _context.AccessReviews.Include(x => x.Items).FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
public async Task<IEnumerable<AccessReview>> GetByOwnerIdAsync(Guid ownerId, CancellationToken cancellationToken = default)
=> await _context.AccessReviews
.Where(x => EF.Property<Guid>(x, "_ownerId") == ownerId)
.OrderByDescending(x => EF.Property<DateTime>(x, "_createdAt"))
.ToListAsync(cancellationToken);
public async Task<IEnumerable<AccessReview>> GetActiveReviewsAsync(CancellationToken cancellationToken = default)
=> await _context.AccessReviews
.Where(x => EF.Property<AccessReviewStatus>(x, "_status") == AccessReviewStatus.Active)
.ToListAsync(cancellationToken);
}

View File

@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using IamService.Domain.AggregatesModel.PrivilegedAccessAggregate;
using IamService.Domain.SeedWork;
namespace IamService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for PrivilegedAccessGrant aggregate.
/// VI: Repository implementation cho PrivilegedAccessGrant aggregate.
/// </summary>
public class PrivilegedAccessRepository : IPrivilegedAccessRepository
{
private readonly IamServiceContext _context;
public IUnitOfWork UnitOfWork => _context;
public PrivilegedAccessRepository(IamServiceContext context) => _context = context;
public PrivilegedAccessGrant Add(PrivilegedAccessGrant grant) => _context.PrivilegedAccessGrants.Add(grant).Entity;
public void Update(PrivilegedAccessGrant grant) => _context.Entry(grant).State = EntityState.Modified;
public async Task<PrivilegedAccessGrant?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
=> await _context.PrivilegedAccessGrants.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
public async Task<IEnumerable<PrivilegedAccessGrant>> GetActiveByUserIdAsync(Guid userId, CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
return await _context.PrivilegedAccessGrants
.Where(x => EF.Property<Guid>(x, "_userId") == userId)
.Where(x => EF.Property<PrivilegedAccessStatus>(x, "_status") == PrivilegedAccessStatus.Active)
.Where(x => EF.Property<DateTime>(x, "_startsAt") <= now)
.Where(x => EF.Property<DateTime>(x, "_expiresAt") > now)
.ToListAsync(cancellationToken);
}
public async Task<IEnumerable<PrivilegedAccessGrant>> GetExpiredGrantsAsync(CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
return await _context.PrivilegedAccessGrants
.Where(x => EF.Property<PrivilegedAccessStatus>(x, "_status") == PrivilegedAccessStatus.Active)
.Where(x => EF.Property<DateTime>(x, "_expiresAt") <= now)
.ToListAsync(cancellationToken);
}
public async Task<bool> HasActiveGrantAsync(Guid userId, Guid roleId, CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
return await _context.PrivilegedAccessGrants
.AnyAsync(x =>
EF.Property<Guid>(x, "_userId") == userId &&
EF.Property<Guid>(x, "_roleId") == roleId &&
EF.Property<PrivilegedAccessStatus>(x, "_status") == PrivilegedAccessStatus.Active &&
EF.Property<DateTime>(x, "_startsAt") <= now &&
EF.Property<DateTime>(x, "_expiresAt") > now,
cancellationToken);
}
}