313 lines
15 KiB
C#
313 lines
15 KiB
C#
using Marco.Pms.DataAccess.Data;
|
|
using Marco.Pms.Model.Employees;
|
|
using Marco.Pms.Model.Entitlements;
|
|
using Marco.Pms.Services.Helpers;
|
|
using MarcoBMS.Services.Helpers;
|
|
using MarcoBMS.Services.Service;
|
|
using Microsoft.CodeAnalysis;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Marco.Pms.Services.Service
|
|
{
|
|
public class PermissionServices
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
private readonly CacheUpdateHelper _cache;
|
|
private readonly ILoggingService _logger;
|
|
private readonly Guid tenantId;
|
|
|
|
public PermissionServices(ApplicationDbContext context, CacheUpdateHelper cache, ILoggingService logger, UserHelper userHelper)
|
|
{
|
|
_context = context;
|
|
_cache = cache;
|
|
_logger = logger;
|
|
tenantId = userHelper.GetTenantId();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether an employee has a specific feature permission, optionally within a project context.
|
|
/// </summary>
|
|
/// <param name="featurePermissionId">The target feature permission ID to check.</param>
|
|
/// <param name="employeeId">The ID of the employee.</param>
|
|
/// <param name="projectId">Optional project ID for project-scoped permissions.</param>
|
|
/// <returns>True if the user has the permission, otherwise false.</returns>
|
|
public async Task<bool> HasPermission(Guid featurePermissionId, Guid employeeId, Guid? projectId = null)
|
|
{
|
|
var featurePermissionIds = await GetPermissionIdsByEmployeeId(employeeId, projectId);
|
|
|
|
return featurePermissionIds.Contains(featurePermissionId);
|
|
}
|
|
public async Task<bool> HasPermissionAny(List<Guid> featurePermissionIds, Guid employeeId)
|
|
{
|
|
var allFeaturePermissionIds = await GetPermissionIdsByEmployeeId(employeeId);
|
|
|
|
var hasPermission = featurePermissionIds.Any(f => allFeaturePermissionIds.Contains(f));
|
|
|
|
return hasPermission;
|
|
}
|
|
public bool HasPermissionAny(List<Guid> realPermissionIds, List<Guid> toCheckPermissionIds, Guid employeeId)
|
|
{
|
|
var hasPermission = toCheckPermissionIds.Any(f => realPermissionIds.Contains(f));
|
|
return hasPermission;
|
|
}
|
|
public async Task<bool> HasProjectPermission(Employee LoggedInEmployee, Guid projectId)
|
|
{
|
|
var employeeId = LoggedInEmployee.Id;
|
|
var projectIds = await _cache.GetProjects(employeeId, tenantId);
|
|
|
|
if (projectIds == null)
|
|
{
|
|
var hasPermission = await HasPermission(PermissionsMaster.ManageProject, employeeId);
|
|
if (hasPermission)
|
|
{
|
|
var projects = await _context.Projects.AsNoTracking().Where(c => c.TenantId == LoggedInEmployee.TenantId).ToListAsync();
|
|
projectIds = projects.Select(p => p.Id).ToList();
|
|
}
|
|
else
|
|
{
|
|
var allocation = await _context.ProjectAllocations.AsNoTracking().Where(c => c.EmployeeId == employeeId && c.IsActive).ToListAsync();
|
|
if (!allocation.Any())
|
|
{
|
|
return false;
|
|
}
|
|
projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList();
|
|
}
|
|
await _cache.AddProjects(LoggedInEmployee.Id, projectIds, tenantId);
|
|
}
|
|
return projectIds.Contains(projectId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines if an employee has permission to access a specific service project.
|
|
/// Permission is granted if the user is directly allocated to the project OR
|
|
/// assigned to any active job ticket within the project.
|
|
/// </summary>
|
|
/// <param name="loggedInEmployeeId">The ID of the user requesting access.</param>
|
|
/// <param name="projectId">The ID of the project to access.</param>
|
|
/// <returns>True if access is allowed, otherwise False.</returns>
|
|
public async Task<bool> HasServiceProjectPermission(Guid loggedInEmployeeId, Guid projectId)
|
|
{
|
|
Guid ReviewDoneStatus = Guid.Parse("ed10ab57-dbaa-4ca5-8ecd-56745dcbdbd7");
|
|
Guid ClosedStatus = Guid.Parse("3ddeefb5-ae3c-4e10-a922-35e0a452bb69");
|
|
// 1. Input Validation
|
|
if (loggedInEmployeeId == Guid.Empty || projectId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning("Permission check failed: Invalid input parameters. EmployeeId: {EmployeeId}, ProjectId: {ProjectId}", loggedInEmployeeId, projectId);
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
_logger.LogInfo("Starting permission check for Employee: {EmployeeId} on Project: {ProjectId}", loggedInEmployeeId, projectId);
|
|
|
|
// 2. Check Level 1: Is the user a generic Team Member of the project?
|
|
// This is usually the most common case, so checking this first saves complex query execution.
|
|
bool isTeamMember = await _context.ServiceProjectAllocations
|
|
.AsNoTracking() // Optimization: Read-only query does not need tracking
|
|
.AnyAsync(spa => spa.ProjectId == projectId
|
|
&& spa.EmployeeId == loggedInEmployeeId
|
|
&& spa.IsActive
|
|
&& spa.TenantId == tenantId);
|
|
|
|
if (isTeamMember)
|
|
{
|
|
_logger.LogInfo("Access Granted: User {EmployeeId} is a team member of Project {ProjectId}.", loggedInEmployeeId, projectId);
|
|
return true;
|
|
}
|
|
|
|
// 3. Check Level 2: Is the user assigned to any ACTIVE specific Job Ticket?
|
|
// Optimization: Combined the check for JobTicket and Mapping into a single Join query.
|
|
// This prevents pulling a list of JobIds into memory (fixing memory bloat) and reduces DB roundtrips.
|
|
bool hasActiveJobAssignment = await _context.JobTickets
|
|
.AsNoTracking()
|
|
.Where(jt => jt.ProjectId == projectId
|
|
&& jt.StatusId != ReviewDoneStatus
|
|
&& jt.StatusId != ClosedStatus
|
|
&& jt.IsActive)
|
|
.Join(_context.JobEmployeeMappings,
|
|
ticket => ticket.Id,
|
|
mapping => mapping.JobTicketId,
|
|
(ticket, mapping) => mapping)
|
|
.AnyAsync(mapping => mapping.AssigneeId == loggedInEmployeeId
|
|
&& mapping.TenantId == tenantId);
|
|
|
|
if (hasActiveJobAssignment)
|
|
{
|
|
_logger.LogInfo("Access Granted: User {EmployeeId} is assigned active tickets in Project {ProjectId}.", loggedInEmployeeId, projectId);
|
|
return true;
|
|
}
|
|
|
|
// 4. Default Deny
|
|
_logger.LogWarning("Access Denied: User {EmployeeId} has no permissions for Project {ProjectId}.", loggedInEmployeeId, projectId);
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// 5. Robust Error Handling
|
|
// Log the full stack trace for debugging, but return false to maintain security (fail-closed).
|
|
_logger.LogError(ex, "An error occurred while checking permissions for Employee: {EmployeeId} on Project: {ProjectId}", loggedInEmployeeId, projectId);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves permission IDs for an employee, supporting both global role-based permissions
|
|
/// and project-specific overrides with cache-first strategy.
|
|
/// </summary>
|
|
/// <param name="employeeId">The ID of the employee to fetch permissions for.</param>
|
|
/// <param name="projectId">Optional project ID for project-level permission overrides.</param>
|
|
/// <returns>List of unique permission IDs the employee has access to.</returns>
|
|
/// <exception cref="ArgumentException">Thrown when employeeId or tenantId is empty.</exception>
|
|
public async Task<List<Guid>> GetPermissionIdsByEmployeeId(Guid employeeId, Guid? projectId = null)
|
|
{
|
|
// Input validation
|
|
if (employeeId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning("EmployeeId cannot be empty.");
|
|
return new List<Guid>();
|
|
}
|
|
|
|
if (tenantId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning("TenantId cannot be empty.");
|
|
return new List<Guid>();
|
|
}
|
|
_logger.LogDebug(
|
|
"GetPermissionIdsByEmployeeId started. EmployeeId: {EmployeeId}, ProjectId: {ProjectId}, TenantId: {TenantId}",
|
|
employeeId, projectId ?? Guid.Empty, tenantId);
|
|
|
|
try
|
|
{
|
|
// Phase 1: Cache-first lookup for role-based permissions (fast path)
|
|
var featurePermissionIds = await _cache.GetPermissions(employeeId, tenantId);
|
|
var permissionsFromCache = featurePermissionIds != null;
|
|
|
|
_logger.LogDebug(
|
|
"Permission lookup from cache: {CacheHit}, InitialPermissions: {PermissionCount}, EmployeeId: {EmployeeId}",
|
|
permissionsFromCache, featurePermissionIds?.Count ?? 0, employeeId);
|
|
|
|
// Phase 2: Database fallback if cache miss
|
|
if (featurePermissionIds == null)
|
|
{
|
|
_logger.LogDebug(
|
|
"Cache miss detected, falling back to database lookup. EmployeeId: {EmployeeId}, TenantId: {TenantId}",
|
|
employeeId, tenantId);
|
|
|
|
var roleIds = await _context.EmployeeRoleMappings
|
|
.Where(erm => erm.EmployeeId == employeeId && erm.TenantId == tenantId)
|
|
.Select(erm => erm.RoleId)
|
|
.ToListAsync();
|
|
|
|
if (!roleIds.Any())
|
|
{
|
|
_logger.LogDebug(
|
|
"No roles found for employee. EmployeeId: {EmployeeId}, TenantId: {TenantId}",
|
|
employeeId, tenantId);
|
|
return new List<Guid>();
|
|
}
|
|
|
|
featurePermissionIds = await _context.RolePermissionMappings
|
|
.Where(rpm => roleIds.Contains(rpm.ApplicationRoleId))
|
|
.Select(rpm => rpm.FeaturePermissionId)
|
|
.Distinct()
|
|
.ToListAsync();
|
|
|
|
// The cache service might also need its own context, or you can pass the data directly.
|
|
// Assuming AddApplicationRole takes the data, not a context.
|
|
await _cache.AddApplicationRole(employeeId, roleIds, tenantId);
|
|
_logger.LogInfo("Successfully queued cache update for EmployeeId: {EmployeeId}", employeeId);
|
|
|
|
_logger.LogDebug(
|
|
"Loaded {RoleCount} roles → {PermissionCount} permissions from database. EmployeeId: {EmployeeId}",
|
|
roleIds.Count, featurePermissionIds.Count, employeeId);
|
|
}
|
|
|
|
// Early return for global permissions (no project context)
|
|
if (!projectId.HasValue)
|
|
{
|
|
_logger.LogDebug(
|
|
"Returning global permissions. Count: {PermissionCount}, EmployeeId: {EmployeeId}",
|
|
featurePermissionIds.Count, employeeId);
|
|
return featurePermissionIds;
|
|
}
|
|
|
|
// Phase 3: Apply project-level permission overrides
|
|
_logger.LogDebug(
|
|
"Applying project-level overrides. ProjectId: {ProjectId}, EmployeeId: {EmployeeId}",
|
|
projectId.Value, employeeId);
|
|
|
|
var projectLevelPermissionIds = await _context.ProjectLevelPermissionMappings
|
|
.AsNoTracking()
|
|
.Where(pl => pl.ProjectId == projectId.Value &&
|
|
pl.EmployeeId == employeeId)
|
|
.Select(pl => pl.PermissionId)
|
|
.Distinct()
|
|
.ToListAsync();
|
|
|
|
if (!projectLevelPermissionIds.Any())
|
|
{
|
|
_logger.LogDebug(
|
|
"No project-level permissions found. ProjectId: {ProjectId}, EmployeeId: {EmployeeId}",
|
|
projectId.Value, employeeId);
|
|
return featurePermissionIds;
|
|
}
|
|
|
|
// Phase 4: Override logic for specific project modules
|
|
var projectOverrideModules = new HashSet<Guid>
|
|
{
|
|
// Hard-coded module IDs for project-level override scope
|
|
// TODO: Consider moving to configuration or database lookup
|
|
Guid.Parse("53176ebf-c75d-42e5-839f-4508ffac3def"), // Module: Projects
|
|
Guid.Parse("9d4b5489-2079-40b9-bd77-6e1bf90bc19f"), // Module: Expenses
|
|
Guid.Parse("52c9cf54-1eb2-44d2-81bb-524cf29c0a94"), // Module: Invoices
|
|
Guid.Parse("a8cf4331-8f04-4961-8360-a3f7c3cc7462") // Module: Documents
|
|
};
|
|
|
|
// Find permissions in override modules that employee lacks at project level
|
|
var overriddenPermissions = await _context.FeaturePermissions
|
|
.AsNoTracking()
|
|
.Where(fp => projectOverrideModules.Contains(fp.FeatureId) &&
|
|
!projectLevelPermissionIds.Contains(fp.Id))
|
|
.Select(fp => fp.Id)
|
|
.ToListAsync();
|
|
|
|
// Apply override rules:
|
|
// 1. Remove global permissions overridden by project context
|
|
// 2. Add explicit project-level grants
|
|
// 3. Ensure uniqueness
|
|
var finalPermissions = featurePermissionIds
|
|
.Except(overriddenPermissions) // Remove overridden global perms
|
|
.Concat(projectLevelPermissionIds) // Add project-specific grants
|
|
.Distinct() // Deduplicate
|
|
.ToList();
|
|
|
|
_logger.LogDebug(
|
|
"Project override applied. Before: {BeforeCount}, After: {AfterCount}, Added: {AddedCount}, Removed: {RemovedCount}, EmployeeId: {EmployeeId}",
|
|
featurePermissionIds.Count, finalPermissions.Count,
|
|
projectLevelPermissionIds.Count, overriddenPermissions.Count, employeeId);
|
|
|
|
return finalPermissions;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
_logger.LogWarning("GetPermissionIdsByEmployeeId cancelled. EmployeeId: {EmployeeId}", employeeId);
|
|
|
|
return new List<Guid>();
|
|
}
|
|
catch (DbUpdateException ex)
|
|
{
|
|
_logger.LogError(ex, "Database error in GetPermissionIdsByEmployeeId. EmployeeId: {EmployeeId}, TenantId: {TenantId}", employeeId, tenantId);
|
|
|
|
return new List<Guid>();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Unexpected error in GetPermissionIdsByEmployeeId. EmployeeId: {EmployeeId}, TenantId: {TenantId}", employeeId, tenantId);
|
|
|
|
return new List<Guid>();
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|