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(); } /// /// Checks whether an employee has a specific feature permission, optionally within a project context. /// /// The target feature permission ID to check. /// The ID of the employee. /// Optional project ID for project-scoped permissions. /// True if the user has the permission, otherwise false. public async Task HasPermission(Guid featurePermissionId, Guid employeeId, Guid? projectId = null) { var featurePermissionIds = await GetPermissionIdsByEmployeeId(employeeId, projectId); return featurePermissionIds.Contains(featurePermissionId); } public async Task HasPermissionAny(List featurePermissionIds, Guid employeeId) { var allFeaturePermissionIds = await GetPermissionIdsByEmployeeId(employeeId); var hasPermission = featurePermissionIds.Any(f => allFeaturePermissionIds.Contains(f)); return hasPermission; } public bool HasPermissionAny(List realPermissionIds, List toCheckPermissionIds, Guid employeeId) { var hasPermission = toCheckPermissionIds.Any(f => realPermissionIds.Contains(f)); return hasPermission; } public async Task 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); } /// /// 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. /// /// The ID of the user requesting access. /// The ID of the project to access. /// True if access is allowed, otherwise False. public async Task 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; } } /// /// Retrieves permission IDs for an employee, supporting both global role-based permissions /// and project-specific overrides with cache-first strategy. /// /// The ID of the employee to fetch permissions for. /// Optional project ID for project-level permission overrides. /// List of unique permission IDs the employee has access to. /// Thrown when employeeId or tenantId is empty. public async Task> GetPermissionIdsByEmployeeId(Guid employeeId, Guid? projectId = null) { // Input validation if (employeeId == Guid.Empty) { _logger.LogWarning("EmployeeId cannot be empty."); return new List(); } if (tenantId == Guid.Empty) { _logger.LogWarning("TenantId cannot be empty."); return new List(); } _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(); } 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 { // 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(); } catch (DbUpdateException ex) { _logger.LogError(ex, "Database error in GetPermissionIdsByEmployeeId. EmployeeId: {EmployeeId}, TenantId: {TenantId}", employeeId, tenantId); return new List(); } catch (Exception ex) { _logger.LogError(ex, "Unexpected error in GetPermissionIdsByEmployeeId. EmployeeId: {EmployeeId}, TenantId: {TenantId}", employeeId, tenantId); return new List(); } } } }