diff --git a/src/backend/api/Fusion.Resources.Domain/Models/QueryOrgUnitReason.cs b/src/backend/api/Fusion.Resources.Domain/Models/QueryOrgUnitReason.cs index 30d93e8b0..c77b3430e 100644 --- a/src/backend/api/Fusion.Resources.Domain/Models/QueryOrgUnitReason.cs +++ b/src/backend/api/Fusion.Resources.Domain/Models/QueryOrgUnitReason.cs @@ -1,9 +1,44 @@ -namespace Fusion.Resources.Domain.Models +using System.Collections.Generic; +using System; + +namespace Fusion.Resources.Domain.Models { internal class QueryOrgUnitReason { + public QueryOrgUnitReason(string fullDepartment, string reason) + { + IsWildCard = fullDepartment.Trim().EndsWith('*'); + Reason = reason; + FullDepartment = fullDepartment.Replace("*", "").Trim(); + Level = FullDepartment.Split(" ").Length; + IsGlobalRole = string.IsNullOrEmpty(fullDepartment); + + } + public string FullDepartment { get; set; } = null!; public string Reason { get; set; } = null!; - public bool IsWildCard => FullDepartment.Contains('*') ; + public bool IsWildCard { get; set; } + public int Level { get; set; } + public bool IsGlobalRole { get; set; } } -} \ No newline at end of file + + internal struct OrgUnitComparer + { + public OrgUnitComparer(string FullDepartment) + { + this.FullDepartment = FullDepartment; + Level = FullDepartment.Split(" ").Length; + } + + public string FullDepartment { get; } + public int Level { get; set; } + + public bool IsChildOf(OrgUnitComparer other, int maxDistance) + { + var isChild = other.FullDepartment.StartsWith(FullDepartment + " ", StringComparison.OrdinalIgnoreCase); + var distance = Level - other.Level; + + return isChild && distance <= maxDistance; + } + } +} diff --git a/src/backend/api/Fusion.Resources.Domain/Queries/GetRelevantOrgUnits.cs b/src/backend/api/Fusion.Resources.Domain/Queries/GetRelevantOrgUnits.cs index 6b6093a1c..352f7c758 100644 --- a/src/backend/api/Fusion.Resources.Domain/Queries/GetRelevantOrgUnits.cs +++ b/src/backend/api/Fusion.Resources.Domain/Queries/GetRelevantOrgUnits.cs @@ -1,8 +1,10 @@ -using Fusion.AspNetCore.OData; +using Azure.Core; +using Fusion.AspNetCore.OData; using Fusion.Integration; using Fusion.Integration.Profile; using Fusion.Resources.Domain.Models; using MediatR; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -29,11 +31,13 @@ public GetRelevantOrgUnits(string profileId, AspNetCore.OData.ODataQueryParams q public class Handler : IRequestHandler?> { + private readonly IMediator mediator; private readonly IFusionProfileResolver profileResolver; private readonly IOrgUnitCache orgUnitCache; - public Handler(IFusionProfileResolver profileResolver, IOrgUnitCache orgUnitCache) + public Handler(IMediator mediator, IFusionProfileResolver profileResolver, IOrgUnitCache orgUnitCache) { + this.mediator = mediator; this.profileResolver = profileResolver; this.orgUnitCache = orgUnitCache; } @@ -48,29 +52,32 @@ public Handler(IFusionProfileResolver profileResolver, IOrgUnitCache orgUnitCach FullDepartment = x.FullDepartment, Department = x.Department, ShortName = x.ShortName - }); + }).ToList(); var user = await profileResolver.ResolvePersonFullProfileAsync(request.ProfileId.OriginalIdentifier); + + if (user?.Roles is null) + throw new InvalidOperationException("Roles was not loaded for profile. Required to resolve profile manager responsebility."); - if (user?.FullDepartment is null) - { - return null; - } var orgUnitAccessReason = new List(); - orgUnitAccessReason.ApplyManager(user); + var managerForUnits = await ResolveUserManagerUnitAsync(user); + + orgUnitAccessReason.AddRange(managerForUnits); + // Filter out only active roles - var activeRoles = user.Roles.Where(x => x.IsActive); + var activeRoles = user.Roles.Where(x => x.IsActive && (x.OnDemandSupport == false || x.ActiveToUtc > DateTime.UtcNow)); - var delegatedManagerClaims = activeRoles.Where(x => x.Name.StartsWith("Fusion.Resources.ResourceOwner")).Select(x => x.Scope?.Value); + var delegatedManagerClaims = activeRoles.Where(x => x.Name.StartsWith("Fusion.Resources.ResourceOwner")).Where(x => x.Scope?.Value is not null).Select(x => x.Scope?.Value!); orgUnitAccessReason.ApplyRole(delegatedManagerClaims, ReasonRoles.DelegatedManager); - var adminClaims = activeRoles?.Where(x => x.Name.StartsWith("Fusion.Resources.Full") || x.Name.StartsWith("Fusion.Resources.Admin")).Select(x => x.Scope?.Value); + // Must support global roles + var adminClaims = activeRoles.Where(x => x.Name.StartsWith("Fusion.Resources.Full") || x.Name.StartsWith("Fusion.Resources.Admin")).Select(x => x.Scope?.Value ?? "*"); orgUnitAccessReason.ApplyRole(adminClaims, ReasonRoles.Write); - var readClaims = activeRoles?.Where(x => x.Name.StartsWith("Fusion.Resources.Request") || x.Name.StartsWith("Fusion.Resources.Read")).Select(x => x.Scope?.Value); + var readClaims = activeRoles.Where(x => x.Name.StartsWith("Fusion.Resources.Request") || x.Name.StartsWith("Fusion.Resources.Read")).Select(x => x.Scope?.Value ?? "*"); orgUnitAccessReason.ApplyRole(readClaims, ReasonRoles.Read); orgUnitAccessReason.ApplyParentManager(orgUnits, user); @@ -82,7 +89,8 @@ public Handler(IFusionProfileResolver profileResolver, IOrgUnitCache orgUnitCach List populatedOrgUnitResult = GetRelevantOrgUnits(orgUnits, orgUnitAccessReason); - var filteredOrgUnits = ApplyOdataFilters(request.Query, populatedOrgUnitResult.OrderBy(x => x.SapId)); + var filteredOrgUnits = ApplyOdataFilters(request.Query, populatedOrgUnitResult.OrderBy(x => x.FullDepartment)); + var skip = request.Query.Skip.GetValueOrDefault(0); var take = request.Query.Top.GetValueOrDefault(100); @@ -91,132 +99,156 @@ public Handler(IFusionProfileResolver profileResolver, IOrgUnitCache orgUnitCach return pagedQuery; } - private static List GetRelevantOrgUnits(IEnumerable cachedOrgUnits, List orgUnitAccessReason) + private async Task> ResolveUserManagerUnitAsync(FusionFullPersonProfile user) { - var endResult = new List(); - foreach (var org in orgUnitAccessReason) - { + if (user.Roles is null) + throw new InvalidOperationException("Roles was not loaded for profile. Required to resolve profile manager responsebility."); - if (org?.FullDepartment != null && org?.Reason != null) - { - var alreadyInList = endResult.FirstOrDefault(x => x.FullDepartment == org.FullDepartment); + var managerRoles = user.Roles + .Where(x => string.Equals(x.Name, "Fusion.LineOrg.Manager", StringComparison.OrdinalIgnoreCase)) + .Where(x => !string.IsNullOrEmpty(x.Scope?.Value)) + .Select(x => x.Scope?.Value!) + .ToList(); - if (alreadyInList is null) - { - QueryRelevantOrgUnit? data = new(); - if (org.IsWildCard == true) - { - data = cachedOrgUnits.FirstOrDefault(x => x.FullDepartment == org.FullDepartment.Replace('*', ' ').TrimEnd()); - } - else - { - data = cachedOrgUnits.FirstOrDefault(x => x.FullDepartment == org.FullDepartment || x.Department == org.FullDepartment); - } - if (data != null) - { - data.Reasons.Add(org.Reason); - endResult.Add(data); - } - } - else - { - if (!alreadyInList.Reasons.Contains(org.Reason)) - { - alreadyInList.Reasons.Add(org.Reason); - } - } + var managerFor = new List(); + + foreach (var orgUnitId in managerRoles) + { + var orgUnit = await mediator.Send(new ResolveLineOrgUnit(orgUnitId)); + if (orgUnit?.FullDepartment != null) + { + managerFor.Add(new QueryOrgUnitReason(orgUnit.FullDepartment, ReasonRoles.Manager)); } } - return endResult; + + return managerFor; } - private static List ApplyOdataFilters(ODataQueryParams filter, IEnumerable orgUnits) + private static List GetRelevantOrgUnits(IEnumerable cachedOrgUnits, List orgUnitAccessReason) { - var filterGenerator = filter.GenerateFilters(m => - { - m.SqlQueryMode = false; - m.MapField("sapId", e => e.SapId); - m.MapField("name", e => e.Name); - m.MapField("shortName", e => e.ShortName); - m.MapField("department", e => e.Department); - m.MapField("fullDepartment", e => e.FullDepartment); - m.MapField("reason", e => e.Reasons); - }); - return orgUnits.Where(filterGenerator.FilterLambda.Compile()).ToList(); + orgUnitAccessReason.GroupBy(i => i.FullDepartment) + .ToList() + .ForEach(d => + { + var orgUnit = cachedOrgUnits.FirstOrDefault(o => string.Equals(o.FullDepartment, d.Key, StringComparison.OrdinalIgnoreCase)); + if (orgUnit is not null) + { + orgUnit.Reasons = d.Select(i => i.Reason).ToList(); + } + }); + + return cachedOrgUnits.Where(i => i.Reasons.Any()).ToList(); } + private static List ApplyOdataFilters(ODataQueryParams filter, IEnumerable orgUnits) + { + var query = orgUnits.AsQueryable() + .ApplyODataFilters(filter, m => + { + m.SqlQueryMode = false; + m.MapField("sapId", e => e.SapId); + m.MapField("name", e => e.Name); + m.MapField("shortName", e => e.ShortName); + m.MapField("department", e => e.Department); + m.MapField("fullDepartment", e => e.FullDepartment); + + // Disabling reasons, as this is not supported by the filter. + //m.MapField("reason", e => e.Reasons); + }) + .ApplyODataSorting(filter, m => + { + m.MapField("sapId", e => e.SapId); + m.MapField("name", e => e.Name); + m.MapField("shortName", e => e.ShortName); + m.MapField("department", e => e.Department); + m.MapField("fullDepartment", e => e.FullDepartment); + }, + q => q.OrderBy(o => o.FullDepartment)); + + return query.ToList(); + } } } internal static class OrgUnitAccessReasons { - internal static void ApplyManager(this List reasons, FusionFullPersonProfile user) + + public static bool IsDirectChildOf(this QueryRelevantOrgUnit orgUnit, QueryOrgUnitReason unit) { - var isDepartmentManager = user.IsResourceOwner; - if (isDepartmentManager) - reasons.Add(new QueryOrgUnitReason - { - FullDepartment = user?.FullDepartment ?? "", - Reason = ReasonRoles.Manager - }); + var item = new OrgUnitComparer(orgUnit.FullDepartment); + var distance = item.Level - unit.Level; + + return item.FullDepartment.StartsWith(unit.FullDepartment) && distance == 1; } - internal static void ApplyRole(this List reasons, IEnumerable? departments, string role) + internal static void ApplyRole(this List reasons, IEnumerable departments, string role) { - if (departments is not null) - { - reasons.AddRange(departments.Select(dep => new QueryOrgUnitReason - { - FullDepartment = dep ?? "*", - Reason = role - })); - } + reasons.AddRange(departments.Select(d => new QueryOrgUnitReason(d, role))); } internal static void ApplyParentManager(this List reasons, IEnumerable orgUnits, FusionFullPersonProfile user) { var managerResposibility = new List(); - var managerOrDelegatedManagerDepartmentsOrWildcard = reasons - .Where(x => x.Reason.Equals(ReasonRoles.Manager) || x.Reason.Equals(ReasonRoles.DelegatedManager) || x.IsWildCard).ToList(); - if (managerOrDelegatedManagerDepartmentsOrWildcard is not null) + + // Process locations where user is natural manager. We want to grant the user access to all child departments. + foreach (var managerUnit in reasons.Where(x => x.Reason == ReasonRoles.Manager)) { - foreach (var department in managerOrDelegatedManagerDepartmentsOrWildcard) - { - var parentDepartment = department.FullDepartment.Replace("*", "").TrimEnd(); + var childDepartments = orgUnits + .Where(x => x.FullDepartment.StartsWith(managerUnit.FullDepartment)) + .Where(x => x.IsDirectChildOf(managerUnit)); // Include trailing space so we do not include the actual unit where user is manager. - var childDepartments = orgUnits.Distinct().Where(x => x.FullDepartment.StartsWith(parentDepartment) && !x.FullDepartment.Equals(parentDepartment)); + managerResposibility.AddRange(childDepartments.Select(d => new QueryOrgUnitReason(d.FullDepartment, ReasonRoles.ParentManager))); + } - // if the department is not of type wildcard we only want to get direct children (one level below) - if (!department.IsWildCard) - { - var getParentDepartmentLevel = GetAcronymsForDepartment(parentDepartment); + // Process delegate manager. + foreach (var delegatedManager in reasons.Where(x => x.Reason == ReasonRoles.DelegatedManager)) + { + var childDepartments = orgUnits.Where(x => x.FullDepartment.StartsWith(delegatedManager.FullDepartment + " ")); - childDepartments = childDepartments.Where(x => (GetAcronymsForDepartment(x.FullDepartment).Count() == getParentDepartmentLevel.Length + 1)); + if (delegatedManager.IsWildCard) + { + // Add all child org units as delegated manager role. + managerResposibility.AddRange(childDepartments.Select(d => new QueryOrgUnitReason(d.FullDepartment, ReasonRoles.DelegatedManager))); + } + else + { + // Add just direct children + managerResposibility.AddRange(childDepartments.Where(d => d.IsDirectChildOf(delegatedManager)).Select(d => new QueryOrgUnitReason(d.FullDepartment, ReasonRoles.DelegatedParentManager))); + } + } - } - var reason = ReasonRoles.DelegatedParentManager; - if (user.IsResourceOwner && user.FullDepartment == parentDepartment) - { - reason = ReasonRoles.ParentManager; - } + // Process read/write roles - foreach (var child in childDepartments) - { - managerResposibility?.Add(new QueryOrgUnitReason - { - FullDepartment = child.FullDepartment, - Reason = reason - }); - } + foreach (var delegatedManager in reasons.Where(x => x.Reason == ReasonRoles.Write && x.IsWildCard)) + { + if (delegatedManager.IsGlobalRole) + { + managerResposibility.AddRange(orgUnits.Select(d => new QueryOrgUnitReason(d.FullDepartment, ReasonRoles.Write))); } - reasons?.AddRange(managerResposibility); + else + { + var childDepartments = orgUnits.Where(x => x.FullDepartment.StartsWith(delegatedManager.FullDepartment) && x.FullDepartment != delegatedManager.FullDepartment); + managerResposibility.AddRange(childDepartments.Select(d => new QueryOrgUnitReason(d.FullDepartment, ReasonRoles.Write))); + } + } - } - static string[] GetAcronymsForDepartment(string word) - { - return word.Split(); + foreach (var delegatedManager in reasons.Where(x => x.Reason == ReasonRoles.Read && x.IsWildCard)) + { + if (delegatedManager.IsGlobalRole) + { + managerResposibility.AddRange(orgUnits.Select(d => new QueryOrgUnitReason(d.FullDepartment, ReasonRoles.Read))); + } + else + { + var childDepartments = orgUnits.Where(x => x.FullDepartment.StartsWith(delegatedManager.FullDepartment) && x.FullDepartment != delegatedManager.FullDepartment); + managerResposibility.AddRange(childDepartments.Select(d => new QueryOrgUnitReason(d.FullDepartment, ReasonRoles.Read))); + } + } + + // Mutate at the end + reasons.AddRange(managerResposibility); } } } \ No newline at end of file diff --git a/src/backend/api/Fusion.Resources.Domain/Queries/GetResourceOwnerProfile.cs b/src/backend/api/Fusion.Resources.Domain/Queries/GetResourceOwnerProfile.cs index 9dfc86d38..a9d955462 100644 --- a/src/backend/api/Fusion.Resources.Domain/Queries/GetResourceOwnerProfile.cs +++ b/src/backend/api/Fusion.Resources.Domain/Queries/GetResourceOwnerProfile.cs @@ -47,19 +47,20 @@ public Handler(ILogger logger, IFusionProfileResolver profileResolver, public async Task Handle(GetResourceOwnerProfile request, CancellationToken cancellationToken) { - var user = await profileResolver.ResolvePersonBasicProfileAsync(request.ProfileId.OriginalIdentifier); + var user = await profileResolver.ResolvePersonFullProfileAsync(request.ProfileId.OriginalIdentifier); if (user is null) return null; - var sector = await ResolveSector(user.FullDepartment); + var userIsManagerFor = await GetUserManagerForAsync(user); + var sector = await ResolveSector(user, userIsManagerFor); // Resolve departments with responsibility - var departmentsWithResponsibility = await ResolveDepartmentsWithResponsibilityAsync(user); + var departmentsWithResponsibility = await ResolveDepartmentsWithResponsibilityAsync(user, userIsManagerFor); // Determine if the user is a manager in the department he/she belongs to. var isDepartmentManager = departmentsWithResponsibility.Any(r => r == user.FullDepartment); - var relevantSectors = await ResolveRelevantSectorsAsync(user.FullDepartment, sector, isDepartmentManager, departmentsWithResponsibility); + var relevantSectors = ResolveRelevantSectors(departmentsWithResponsibility); var relevantDepartments = new List(); foreach (var relevantSector in relevantSectors) @@ -83,30 +84,37 @@ public Handler(ILogger logger, IFusionProfileResolver profileResolver, return resourceOwnerProfile; } - private async Task> ResolveRelevantSectorsAsync(string? fullDepartment, string? sector, bool isDepartmentManager, IEnumerable departmentsWithResponsibility) + private async Task> GetUserManagerForAsync(FusionFullPersonProfile user) { - // Get sectors the user have responsibility in, to find all relevant departments - var relevantSectors = new List(); - foreach (var department in departmentsWithResponsibility) + var orgUnits = new List(); + + var managerRoles = (user.Roles ?? new List()) + .Where(x => string.Equals(x.Name, "Fusion.LineOrg.Manager", StringComparison.OrdinalIgnoreCase)) + .Where(x => !string.IsNullOrEmpty(x.Scope?.Value)) + .Select(x => x.Scope?.Value!) + .ToList(); + + foreach (var orgUnitId in managerRoles) { - var resolvedSector = await ResolveSector(department); - if (resolvedSector != null) + var orgUnit = await mediator.Send(new ResolveLineOrgUnit(orgUnitId)); + if (orgUnit?.FullDepartment != null) { - relevantSectors.Add(resolvedSector); + orgUnits.Add(orgUnit.FullDepartment); } } - // If the sector does not exist, the person might be higher up. - if (sector is null && isDepartmentManager) + return orgUnits; + } + private List ResolveRelevantSectors(IEnumerable departmentsWithResponsibility) + { + // Get sectors the user have responsibility in, to find all relevant departments + var relevantSectors = new List(); + foreach (var department in departmentsWithResponsibility) { - var downstreamSectors = await ResolveDownstreamSectors(fullDepartment); - foreach (var department in downstreamSectors) + var resolvedSector = GetSectorForDepartment(department); + if (resolvedSector != null) { - var resolvedSector = await ResolveSector(department); - if (resolvedSector != null) - { - relevantSectors.Add(resolvedSector); - } + relevantSectors.Add(resolvedSector); } } @@ -115,15 +123,19 @@ private async Task> ResolveRelevantSectorsAsync(string? fullDepartm .ToList(); } - private async Task> ResolveDepartmentsWithResponsibilityAsync(FusionPersonProfile user) + private string? GetSectorForDepartment(string department) { - var isDepartmentManager = user.IsResourceOwner; + var path = new DepartmentPath(department); + var sector = (path.Level > 1) ? path.Parent() : null; + return sector; + } + private async Task> ResolveDepartmentsWithResponsibilityAsync(FusionFullPersonProfile user, List userIsManagerFor) + { var departmentsWithResponsibility = new List(); // Add the current department if the user is resource owner in the department. - if (isDepartmentManager && user.FullDepartment != null) - departmentsWithResponsibility.Add(user.FullDepartment); + departmentsWithResponsibility.AddRange(userIsManagerFor); // Add all departments the user has been delegated responsibility for. var delegatedResponsibilities = db.DelegatedDepartmentResponsibles @@ -146,31 +158,25 @@ private async Task> ResolveDepartmentsWithResponsibilityAsync(Fusio return departmentsWithResponsibility.Distinct().ToList(); } - private async Task ResolveSector(string? department) + private async Task ResolveSector(FusionFullPersonProfile profile, List userIsManagerFor) { - if (string.IsNullOrEmpty(department)) + if (profile.IsResourceOwner) + return profile.FullDepartment; + + if (string.IsNullOrEmpty(profile.FullDepartment)) return null; - var request = new GetDepartmentSector(department); - return await mediator.Send(request); + return await mediator.Send(new GetDepartmentSector(profile.FullDepartment)); } + + private async Task> ResolveSectorDepartments(string sector) { var departments = await mediator.Send(new GetDepartments().InSector(sector)); return departments .Select(dpt => dpt.FullDepartment); } - - private async Task> ResolveDownstreamSectors(string? department) - { - if (department is null) - return Array.Empty(); - - var departments = await mediator.Send(new GetDepartments().StartsWith(department)); - return departments - .Select(dpt => dpt.SectorId!).Distinct(); - } } } } \ No newline at end of file