diff --git a/roles/database/files/sql/creation/fworch-fill-config.sql b/roles/database/files/sql/creation/fworch-fill-config.sql index 2de7bcfc62..db1ab48339 100644 --- a/roles/database/files/sql/creation/fworch-fill-config.sql +++ b/roles/database/files/sql/creation/fworch-fill-config.sql @@ -139,6 +139,7 @@ insert into config (config_key, config_value, config_user) VALUES ('modModelledM insert into config (config_key, config_value, config_user) VALUES ('modModelledMarkerLocation', 'rulename', 0); insert into config (config_key, config_value, config_user) VALUES ('ruleRecognitionOption', '{"nwRegardIp":true,"nwRegardName":false,"nwRegardGroupName":false,"nwResolveGroup":false,"svcRegardPortAndProt":true,"svcRegardName":false,"svcRegardGroupName":false,"svcResolveGroup":true,"svcSplitPortRanges":false}', 0); insert into config (config_key, config_value, config_user) VALUES ('availableReportTypes', '[1,2,3,4,5,6,7,8,9,10,21,22,31,32]', 0); +insert into config (config_key, config_value, config_user) VALUES ('reportTypeVisibilityByRole', '{}', 0); insert into config (config_key, config_value, config_user) VALUES ('varianceAnalysisSleepTime', '0', 0); insert into config (config_key, config_value, config_user) VALUES ('varianceAnalysisStartAt', '00:00:00', 0); insert into config (config_key, config_value, config_user) VALUES ('varianceAnalysisSync', 'false', 0); diff --git a/roles/database/files/sql/idempotent/fworch-texts.sql b/roles/database/files/sql/idempotent/fworch-texts.sql index 06c8f4ef97..5dd75c8847 100644 --- a/roles/database/files/sql/idempotent/fworch-texts.sql +++ b/roles/database/files/sql/idempotent/fworch-texts.sql @@ -543,6 +543,7 @@ INSERT INTO txt VALUES ('whats_new_facts', 'German', '
  • Compliance-Diff-Berichte können optional auf Regeln beschränkt werden, die im ausgewählten Zeitraum neu nicht konform geworden sind.
  • In den allgemeinen Flow-Einstellungen kann über Namensmuster festgelegt werden, welche Flow-Netzwerkgruppen Zonen sind; der REST-Endpunkt flow/getAddressGroups kann diese Zonen getrennt ausliefern.
  • Flow-Netzwerkobjekte aus Anträgen speichern ihre Adressbereiche als Host-Adressen; ein Bereich aus IPv4 und IPv6 wird abgelehnt.
  • +
  • Für einzelne Rollen kann die Sichtbarkeit jedes Reporttyps auf sichtbar, nicht sichtbar oder geerbt (vom Standardverhalten) gesetzt werden.
  • Details: siehe Release Notes.
  • '); @@ -560,6 +561,7 @@ INSERT INTO txt VALUES ('whats_new_facts', 'English', '
  • Compliance diff reports can optionally be limited to rules that newly became non-compliant during the selected interval.
  • The general flow settings define through name patterns which flow network groups are zones; the REST endpoint flow/getAddressGroups can return those zones as a separate list.
  • Flow network objects created from requests store their address ranges as host addresses; a range mixing IPv4 and IPv6 is refused.
  • +
  • For individual roles, the visibility of each report type can be set to visible, not visible, or inherited (from the default behaviour).
  • Details: see release notes.
  • '); @@ -3302,6 +3304,16 @@ INSERT INTO txt VALUES ('select_file', 'German', 'Datei auswähle INSERT INTO txt VALUES ('select_file', 'English', 'Select file'); INSERT INTO txt VALUES ('availableReportTypes', 'German', 'Verfügbare Reporttypen'); INSERT INTO txt VALUES ('availableReportTypes', 'English', 'Available Report Types'); +INSERT INTO txt VALUES ('reportTypeVisibilityByRole', 'German', 'Reporttyp-Sichtbarkeit je Rolle'); +INSERT INTO txt VALUES ('reportTypeVisibilityByRole', 'English', 'Report Type Visibility by Role'); +INSERT INTO txt VALUES ('role', 'German', 'Rolle'); +INSERT INTO txt VALUES ('role', 'English', 'Role'); +INSERT INTO txt VALUES ('inherited', 'German', 'Geerbt'); +INSERT INTO txt VALUES ('inherited', 'English', 'Inherited'); +INSERT INTO txt VALUES ('visible', 'German', 'Sichtbar'); +INSERT INTO txt VALUES ('visible', 'English', 'Visible'); +INSERT INTO txt VALUES ('not_visible', 'German', 'Nicht sichtbar'); +INSERT INTO txt VALUES ('not_visible', 'English', 'Not Visible'); INSERT INTO txt VALUES ('varianceAnalysisSync', 'German', 'Synchroner Soll-Ist-Abgleich'); INSERT INTO txt VALUES ('varianceAnalysisSync', 'English', 'Synchroneous Variance Analysis'); INSERT INTO txt VALUES ('varianceAnalysisRefresh','German', 'Soll-Ist-Abgleich auf Schaltfläche'); @@ -6535,6 +6547,8 @@ INSERT INTO txt VALUES ('H5458', 'German', 'Rezertifizierungsmodus: Methode der INSERT INTO txt VALUES ('H5458', 'English', 'Recertification Mode: Type of recertification.'); INSERT INTO txt VALUES ('H5459', 'German', 'Rezert Check - Benachrichtigungen: Legt fest, wer in welchem Zeitintervall vor dem Rezert-Termin benachrichtigt wird.'); INSERT INTO txt VALUES ('H5459', 'English', 'Recert Check - Notifications: Defines who will be notified in which time interval before the recert deadline.'); +INSERT INTO txt VALUES ('H5460', 'German', 'Reporttyp-Sichtbarkeit je Rolle: Für eine ausgewählte Rolle kann je Reporttyp festgelegt werden, ob dieser sichtbar, nicht sichtbar oder geerbt (vom Standardverhalten der verfügbaren Reporttypen) sein soll. Diese Einstellung schränkt die Liste der verfügbaren Reporttypen zusätzlich für Nutzer der jeweiligen Rolle ein bzw. erweitert sie.'); +INSERT INTO txt VALUES ('H5460', 'English', 'Report Type Visibility by Role: For a selected role, each report type can be set to visible, not visible or inherited (from the default behaviour of the available report types). This setting additionally restricts or extends the list of available report types for users of the respective role.'); INSERT INTO txt VALUES ('H5461', 'German', 'Jeder Nutzer kann seine eigene bevorzugte Sprache für die Anwendung einstellen.
    Alle Texte werden in dieser Sprache dargestellt, soweit verfügbar. Wenn nicht, wird die Standardsprache verwendet. Wenn der Text auch dort nicht verfügbar ist, wird Englisch genutzt. Die Standardsprache beim ersten Anmelden kann vom Admin für alle Nutzer in den Standardeinstellungen definiert werden.

    diff --git a/roles/lib/files/FWO.Api.Client/ApiConnectionRoleScopeExtensions.cs b/roles/lib/files/FWO.Api.Client/ApiConnectionRoleScopeExtensions.cs index 81e84848d1..53c030bb10 100644 --- a/roles/lib/files/FWO.Api.Client/ApiConnectionRoleScopeExtensions.cs +++ b/roles/lib/files/FWO.Api.Client/ApiConnectionRoleScopeExtensions.cs @@ -99,44 +99,58 @@ public static Task RunWithReportingRole(this ApiConnection api } /// - /// Sets the best available role for generating the given report type. + /// Sets the best available role for generating the given report type. Roles listed in + /// (e.g. roles explicitly set to "Not Visible" for this + /// report type) are never selected, even if the user holds them. /// public static void SetBestRoleForReport(this ApiConnection apiConnection, ClaimsPrincipal user, - ReportType reportType) + ReportType reportType, IEnumerable? excludedRoles = null) { - apiConnection.SetBestRole(user, GetReportRoles(reportType)); + apiConnection.SetBestRole(user, GetReportRoles(reportType, excludedRoles)); } /// - /// Runs an API operation with the best available role for the given report type. + /// Runs an API operation with the best available role for the given report type. Roles listed + /// in (e.g. roles explicitly set to "Not Visible" for this + /// report type) are never selected, even if the user holds them. /// public static Task RunWithBestRoleForReport(this ApiConnection apiConnection, - ClaimsPrincipal user, ReportType reportType, Func> action) + ClaimsPrincipal user, ReportType reportType, Func> action, IEnumerable? excludedRoles = null) { - return apiConnection.RunWithBestRole(user, GetReportRoles(reportType), action); + return apiConnection.RunWithBestRole(user, GetReportRoles(reportType, excludedRoles), action); } - private static List GetReportRoles(ReportType reportType) + private static List GetReportRoles(ReportType reportType, IEnumerable? excludedRoles = null) { + List roles; if (reportType == ReportType.Owners || reportType.IsComplianceReport()) { - return [Roles.Admin, Roles.FwAdmin, Roles.Auditor]; + roles = [Roles.Admin, Roles.FwAdmin, Roles.Auditor]; } - if (reportType.IsModellingReport()) + else if (reportType.IsModellingReport()) { - return [Roles.Admin, Roles.Modeller, Roles.Recertifier, Roles.Auditor]; + roles = [Roles.Admin, Roles.Modeller, Roles.Recertifier, Roles.Auditor]; } - if (reportType.IsWorkflowReport()) + else if (reportType.IsWorkflowReport()) { - return [Roles.Admin, Roles.FwAdmin, Roles.Auditor, Roles.Requester, + roles = [Roles.Admin, Roles.FwAdmin, Roles.Auditor, Roles.Requester, Roles.Approver, Roles.Planner, Roles.Implementer, Roles.Reviewer]; } - if (reportType.IsDeviceRelatedReport()) + else if (reportType.IsDeviceRelatedReport()) { - return [Roles.Admin, Roles.FwAdmin, Roles.ReporterViewAll, Roles.Reporter, + roles = [Roles.Admin, Roles.FwAdmin, Roles.ReporterViewAll, Roles.Reporter, Roles.Recertifier, Roles.Auditor]; } - return ReportingRoles; + else + { + roles = [.. ReportingRoles]; + } + + if (excludedRoles != null) + { + roles = [.. roles.Where(role => !excludedRoles.Contains(role, StringComparer.OrdinalIgnoreCase))]; + } + return roles; } /// diff --git a/roles/lib/files/FWO.Basics/ReportType.cs b/roles/lib/files/FWO.Basics/ReportType.cs index 533dbf4549..c0eef235d4 100644 --- a/roles/lib/files/FWO.Basics/ReportType.cs +++ b/roles/lib/files/FWO.Basics/ReportType.cs @@ -30,6 +30,97 @@ public enum ReportType Owners = 51 } + /// + /// Per-role override for whether a report type is shown/usable. "Inherited" falls back to the + /// standard role-category and global-availability rules; "Visible"/"NotVisible" override them. + /// + public enum ReportTypeVisibilityOption + { + Inherited = 0, + Visible = 1, + NotVisible = 2 + } + + /// + /// Parses and serializes the per-role report-type visibility overrides stored in + /// ConfigData.ReportTypeVisibilityByRole. Report types are keyed by their underlying + /// int value on the wire (not by enum name) to keep JSON dictionary-key serialization simple + /// and stable. + /// + public static class ReportTypeRoleVisibilityConfig + { + public static Dictionary> Parse(string? json) + { + Dictionary> result = []; + if (string.IsNullOrWhiteSpace(json)) + { + return result; + } + + Dictionary>? raw; + try + { + raw = System.Text.Json.JsonSerializer.Deserialize>>(json); + } + catch (System.Text.Json.JsonException) + { + return result; + } + + if (raw == null) + { + return result; + } + + foreach (KeyValuePair> roleEntry in raw) + { + Dictionary byType = []; + foreach (KeyValuePair typeEntry in roleEntry.Value) + { + byType[(ReportType)typeEntry.Key] = typeEntry.Value; + } + result[roleEntry.Key] = byType; + } + return result; + } + + public static string Serialize(Dictionary> data) + { + Dictionary> raw = []; + foreach (KeyValuePair> roleEntry in data) + { + Dictionary byType = []; + foreach (KeyValuePair typeEntry in roleEntry.Value) + { + // Only persist explicit overrides - "Inherited" is the implicit default and need not be stored. + if (typeEntry.Value != ReportTypeVisibilityOption.Inherited) + { + byType[(int)typeEntry.Key] = typeEntry.Value; + } + } + if (byType.Count > 0) + { + raw[roleEntry.Key] = byType; + } + } + return System.Text.Json.JsonSerializer.Serialize(raw); + } + + public static ReportTypeVisibilityOption GetOption( + Dictionary> data, string role, ReportType reportType) + { + // Role names come from LDAP both when the setting is saved and when it is looked up, + // but compare case-insensitively to stay consistent with ExecutionModeHelper's role matching. + KeyValuePair> roleEntry = data.FirstOrDefault( + entry => entry.Key.Equals(role, StringComparison.OrdinalIgnoreCase)); + if (roleEntry.Value != null && roleEntry.Value.TryGetValue(reportType, out ReportTypeVisibilityOption option)) + { + return option; + } + return ReportTypeVisibilityOption.Inherited; + } + } + public static class ReportTypeGroups { public static bool IsRuleReport(this ReportType reportType) @@ -199,7 +290,18 @@ public static bool IsVisibleTemplateType(this ReportType reportType, ReportVisib || visibility.WorkflowRelated && reportType.IsWorkflowReport()); } - public static List CustomSortReportType(List ListIn, ReportVisibility visibility) + /// + /// Sorts into the canonical report-type display order. + /// + /// + /// When true (the default), entries are additionally required to pass + /// against - suitable when has not yet been + /// filtered for visibility (e.g. ). Pass false when the caller has + /// already computed the visible set per item (e.g. via UserConfig.CanUseReportType, which also + /// honours per-role visibility overrides), so this call only sorts without discarding entries that the + /// coarse-grained alone would not have allowed. + /// + public static List CustomSortReportType(List ListIn, ReportVisibility visibility, bool filterByVisibility = true) { List ListOut = []; List orderedReportTypeList = @@ -221,14 +323,14 @@ public static List CustomSortReportType(List ListIn, Rep ]; foreach (var reportType in orderedReportTypeList.Where(r => ListIn.Contains(r))) { - if (reportType == ReportType.Undefined || reportType.IsVisibleTemplateType(visibility)) + if (reportType == ReportType.Undefined || !filterByVisibility || reportType.IsVisibleTemplateType(visibility)) { ListOut.Add(reportType); } ListIn.Remove(reportType); } - // Finally add only remaining report types the current user may actually see. - ListOut.AddRange(ListIn.Where(reportType => reportType.IsVisibleTemplateType(visibility))); + // Finally add remaining report types, filtering by visibility unless the caller already did so. + ListOut.AddRange(filterByVisibility ? ListIn.Where(reportType => reportType.IsVisibleTemplateType(visibility)) : ListIn); return ListOut; } } diff --git a/roles/lib/files/FWO.Basics/Roles.cs b/roles/lib/files/FWO.Basics/Roles.cs index 56e6f44cf5..73c60fe04e 100644 --- a/roles/lib/files/FWO.Basics/Roles.cs +++ b/roles/lib/files/FWO.Basics/Roles.cs @@ -38,4 +38,31 @@ public static bool IsTechnicalOrAnonymous(string role) } public readonly record struct ReportVisibility(bool RuleRelated, bool ModellingRelated, bool ComplianceRelated, bool OwnerRelated, bool WorkflowRelated); + + /// + /// Single source of truth for which roles fall into each report-category bucket used by + /// . Shared by the aggregate (OR-across-held-roles) visibility + /// check and by the per-role visibility check used to resolve "Inherited" report-type overrides. + /// + public static class ReportVisibilityRoleSets + { + public static readonly string[] RuleRelated = [Roles.Reporter, Roles.ReporterViewAll, Roles.FwAdmin, Roles.Admin, Roles.Auditor, Roles.Recertifier]; + public static readonly string[] ModellingRelated = [Roles.Modeller, Roles.Admin, Roles.Auditor, Roles.Recertifier]; + public static readonly string[] ComplianceRelated = [Roles.Admin, Roles.FwAdmin, Roles.Auditor]; + public static readonly string[] OwnerRelated = [Roles.Admin, Roles.FwAdmin, Roles.Auditor]; + public static readonly string[] WorkflowRelated = [Roles.Admin, Roles.FwAdmin, Roles.Auditor, Roles.Requester, Roles.Approver, Roles.Planner, Roles.Implementer, Roles.Reviewer]; + + /// + /// Computes the report-category visibility for a single role (as opposed to the OR of every role a user holds). + /// + public static ReportVisibility ForRole(string role) + { + return new ReportVisibility( + RuleRelated: RuleRelated.Contains(role, StringComparer.OrdinalIgnoreCase), + ModellingRelated: ModellingRelated.Contains(role, StringComparer.OrdinalIgnoreCase), + ComplianceRelated: ComplianceRelated.Contains(role, StringComparer.OrdinalIgnoreCase), + OwnerRelated: OwnerRelated.Contains(role, StringComparer.OrdinalIgnoreCase), + WorkflowRelated: WorkflowRelated.Contains(role, StringComparer.OrdinalIgnoreCase)); + } + } } diff --git a/roles/lib/files/FWO.Config.Api/Data/ConfigData.cs b/roles/lib/files/FWO.Config.Api/Data/ConfigData.cs index 4e3269d05a..a7ad55e2e4 100644 --- a/roles/lib/files/FWO.Config.Api/Data/ConfigData.cs +++ b/roles/lib/files/FWO.Config.Api/Data/ConfigData.cs @@ -57,6 +57,9 @@ public class ConfigData : ICloneable [JsonProperty("availableReportTypes"), JsonPropertyName("availableReportTypes")] public string AvailableReportTypes { get; set; } = "[]"; + [JsonProperty("reportTypeVisibilityByRole"), JsonPropertyName("reportTypeVisibilityByRole")] + public string ReportTypeVisibilityByRole { get; set; } = "{}"; + [JsonProperty("dataRetentionTime"), JsonPropertyName("dataRetentionTime")] public int DataRetentionTime { get; set; } = 731; diff --git a/roles/lib/files/FWO.Config.Api/UserConfig.cs b/roles/lib/files/FWO.Config.Api/UserConfig.cs index c3fd342e01..5bde8e54dd 100644 --- a/roles/lib/files/FWO.Config.Api/UserConfig.cs +++ b/roles/lib/files/FWO.Config.Api/UserConfig.cs @@ -200,18 +200,128 @@ public bool CanUseAnyRole(IEnumerable targetRoles) public ReportVisibility GetReportVisibility() { return new ReportVisibility( - RuleRelated: CanUseAnyRole(Roles.Reporter, Roles.ReporterViewAll, Roles.FwAdmin, Roles.Admin, Roles.Auditor, Roles.Recertifier), - ModellingRelated: CanUseAnyRole(Roles.Modeller, Roles.Admin, Roles.Auditor, Roles.Recertifier), - ComplianceRelated: CanUseAnyRole(Roles.Admin, Roles.FwAdmin, Roles.Auditor), - OwnerRelated: CanUseAnyRole(Roles.Admin, Roles.FwAdmin, Roles.Auditor), - WorkflowRelated: CanUseAnyRole(Roles.Admin, Roles.FwAdmin, Roles.Auditor, Roles.Requester, Roles.Approver, - Roles.Planner, Roles.Implementer, Roles.Reviewer)); + RuleRelated: CanUseAnyRole(ReportVisibilityRoleSets.RuleRelated), + ModellingRelated: CanUseAnyRole(ReportVisibilityRoleSets.ModellingRelated), + ComplianceRelated: CanUseAnyRole(ReportVisibilityRoleSets.ComplianceRelated), + OwnerRelated: CanUseAnyRole(ReportVisibilityRoleSets.OwnerRelated), + WorkflowRelated: CanUseAnyRole(ReportVisibilityRoleSets.WorkflowRelated)); } + /// + /// Central visibility decision for report types: combines the global on/off switch + /// () with any per-role "Visible"/"Not Visible" override + /// configured in . An explicit per-role override + /// always wins - it can reinstate a report type that was disabled globally, or hide one that wasn't. + /// Only when the role's setting is "Inherited" does the global switch act as the fallback, alongside + /// the standard role-category visibility rules. + /// public bool CanUseReportType(ReportType reportType, bool modellingOwnerAllowed = true) { - return reportType == ReportType.Undefined - || reportType.IsVisibleTemplateType(GetReportVisibility(), modellingOwnerAllowed); + if (reportType == ReportType.Undefined) + { + return true; + } + + List applicableRoles = GetApplicableRoles(); + if (applicableRoles.Count == 0) + { + return false; + } + + Dictionary> overrides = ParseReportTypeVisibilityByRole(); + bool globallyAvailable = ParseAvailableReportTypes().Contains(reportType); + return applicableRoles.Any(role => IsReportTypeVisibleForRole(reportType, role, overrides, modellingOwnerAllowed, globallyAvailable)); + } + + /// + /// Returns the subset of the user's roles for which the given report type is explicitly set + /// to "Not Visible". Used to keep the data-access layer (role selection for report execution) + /// aligned with the UI-facing visibility rules in . + /// + public List GetExplicitlyDeniedRoles(ReportType reportType) + { + if (reportType == ReportType.Undefined) + { + return []; + } + + Dictionary> overrides = ParseReportTypeVisibilityByRole(); + return [.. User.Roles + .Where(role => ReportTypeRoleVisibilityConfig.GetOption(overrides, role, reportType) == ReportTypeVisibilityOption.NotVisible) + .Distinct(StringComparer.OrdinalIgnoreCase)]; + } + + private List GetApplicableRoles() + { + return [.. User.Roles.Where(role => ExecutionModeHelper.IsRoleAvailableInExecutionMode(User.Roles, ExecutionMode, role))]; + } + + private string? cachedReportTypeVisibilityByRoleRaw; + private Dictionary> cachedReportTypeVisibilityByRole = []; + + /// + /// Parses , memoizing the result against the + /// raw config string so repeated calls (e.g. once per report type per render) don't each re-run + /// JSON deserialization when the underlying config value hasn't changed. + /// + private Dictionary> ParseReportTypeVisibilityByRole() + { + if (cachedReportTypeVisibilityByRoleRaw != ReportTypeVisibilityByRole) + { + cachedReportTypeVisibilityByRoleRaw = ReportTypeVisibilityByRole; + cachedReportTypeVisibilityByRole = ReportTypeRoleVisibilityConfig.Parse(ReportTypeVisibilityByRole); + } + return cachedReportTypeVisibilityByRole; + } + + private static bool IsReportTypeVisibleForRole(ReportType reportType, string role, + Dictionary> overrides, bool modellingOwnerAllowed, + bool globallyAvailable) + { + ReportTypeVisibilityOption option = ReportTypeRoleVisibilityConfig.GetOption(overrides, role, reportType); + return option switch + { + // An explicit "Visible" override wins over both the global switch and the coarse-grained + // role-category visibility rules; it must not bypass the per-instance modelling-owner scoping check. + ReportTypeVisibilityOption.Visible => + modellingOwnerAllowed || !reportType.IsModellingReport() || reportType.IsOwnerReport(), + ReportTypeVisibilityOption.NotVisible => false, + _ => globallyAvailable && reportType.IsVisibleTemplateType(ReportVisibilityRoleSets.ForRole(role), modellingOwnerAllowed) + }; + } + + private string? cachedAvailableReportTypesRaw; + private HashSet cachedAvailableReportTypes = []; + + /// + /// Parses , memoizing the result against the raw config + /// string the same way does. Malformed config data is + /// treated as "nothing globally available" rather than throwing, since this now runs on every + /// call. + /// + private HashSet ParseAvailableReportTypes() + { + if (cachedAvailableReportTypesRaw != AvailableReportTypes) + { + cachedAvailableReportTypesRaw = AvailableReportTypes; + cachedAvailableReportTypes = []; + if (!string.IsNullOrWhiteSpace(AvailableReportTypes)) + { + try + { + List? parsed = System.Text.Json.JsonSerializer.Deserialize>(AvailableReportTypes); + if (parsed != null) + { + cachedAvailableReportTypes = [.. parsed]; + } + } + catch (System.Text.Json.JsonException) + { + // Keep the empty set computed above. + } + } + } + return cachedAvailableReportTypes; } public override string GetText(string key) diff --git a/roles/middleware/files/FWO.Middleware.Server/Controllers/ReportController.cs b/roles/middleware/files/FWO.Middleware.Server/Controllers/ReportController.cs index 52928f6b3b..ee993f5726 100644 --- a/roles/middleware/files/FWO.Middleware.Server/Controllers/ReportController.cs +++ b/roles/middleware/files/FWO.Middleware.Server/Controllers/ReportController.cs @@ -43,10 +43,11 @@ public async Task Get([FromBody] ReportGetParameters parameters) string jwt = await authManager.AuthorizeUserAsync(targetUser, validatePassword: false); using ApiConnection apiConnectionUserContext = new GraphQlApiConnection(ConfigFile.ApiServerUri, jwt); ReportTemplate template = await ConvertParameters(parameters); - apiConnectionUserContext.SetBestRoleForReport(User, (ReportType)template.ReportParams.ReportType); + ReportType reportType = (ReportType)template.ReportParams.ReportType; using GlobalConfig globalConfig = await GlobalConfig.ConstructAsync(jwt); using UserConfig userConfig = await UserConfig.ConstructAsync(globalConfig, apiConnectionUserContext, targetUser.DbId); + apiConnectionUserContext.SetBestRoleForReport(User, reportType, userConfig.GetExplicitlyDeniedRoles(reportType)); ReportBase? report = await ReportGenerator.GenerateFromTemplate(template, apiConnectionUserContext, userConfig, DefaultInit.DoNothing); diff --git a/roles/tests-unit/files/FWO.Test/ApiConnectionRoleScopeExtensionsTest.cs b/roles/tests-unit/files/FWO.Test/ApiConnectionRoleScopeExtensionsTest.cs index a0e0a47c70..d96b07262e 100644 --- a/roles/tests-unit/files/FWO.Test/ApiConnectionRoleScopeExtensionsTest.cs +++ b/roles/tests-unit/files/FWO.Test/ApiConnectionRoleScopeExtensionsTest.cs @@ -10,6 +10,8 @@ namespace FWO.Test internal class ApiConnectionRoleScopeExtensionsTest { private static readonly List AppRulesRoles = [Roles.Admin, Roles.Modeller, Roles.Recertifier, Roles.Auditor]; + private static readonly List DeniedAdminAndFwAdminRoles = [Roles.Admin, Roles.FwAdmin]; + private static readonly List DeniedModellerRole = [Roles.Modeller]; [Test] public async Task RunWithNamedRoleScopeUsesExpectedRoles() @@ -68,6 +70,38 @@ public void SetBestRoleForReportUsesExpectedRoles() Roles.Recertifier, Roles.Admin, Roles.Auditor, Roles.FwAdmin]); } + [Test] + public void SetBestRoleForReportExcludesDeniedRoles() + { + TrackingApiConnection connection = new(); + ClaimsPrincipal user = CreateUser(Roles.Admin, Roles.FwAdmin, Roles.ReporterViewAll, Roles.Reporter, Roles.Recertifier, Roles.Auditor); + + connection.SetBestRoleForReport(user, ReportType.Rules, DeniedAdminAndFwAdminRoles); + + Assert.That(connection.LastTargetRoles, Is.EqualTo(new List + { + Roles.ReporterViewAll, Roles.Reporter, Roles.Recertifier, Roles.Auditor + })); + } + + [Test] + public async Task RunWithBestRoleForReport_FallsBackWhenPreferredRoleExcluded() + { + TrackingApiConnection connection = new(); + ClaimsPrincipal user = CreateUser(Roles.Modeller, Roles.Recertifier); + + await connection.RunWithBestRoleForReport(user, ReportType.AppRules, async () => + { + Assert.That(connection.ActiveRole, Is.EqualTo(Roles.Recertifier)); + await Task.CompletedTask; + return true; + }, DeniedModellerRole); + + Assert.That(connection.LastTargetRoles, Is.EqualTo(new List { Roles.Admin, Roles.Recertifier, Roles.Auditor })); + Assert.That(connection.ActiveRole, Is.Empty); + Assert.That(connection.SwitchBackCount, Is.EqualTo(1)); + } + [Test] public async Task RunWithBestRoleForReportUsesAppRulesRoleAndSwitchesBack() { diff --git a/roles/tests-unit/files/FWO.Test/ReportTypeVisibilityTest.cs b/roles/tests-unit/files/FWO.Test/ReportTypeVisibilityTest.cs index a4279cc771..4c922299f1 100644 --- a/roles/tests-unit/files/FWO.Test/ReportTypeVisibilityTest.cs +++ b/roles/tests-unit/files/FWO.Test/ReportTypeVisibilityTest.cs @@ -98,6 +98,105 @@ public void ReportTypeSelection_DoesNotContainArchiveOnlyReport() Assert.That(reportTypes, Does.Not.Contain(ReportType.RecertificationEvent)); } + [Test] + public void CanUseReportType_ExplicitNotVisibleHidesOtherwiseVisibleReportType() + { + UserConfig userConfig = BuildUserConfig(Roles.Modeller); + userConfig.ReportTypeVisibilityByRole = ReportTypeRoleVisibilityConfig.Serialize(new() + { + [Roles.Modeller] = new() { [ReportType.Connections] = ReportTypeVisibilityOption.NotVisible } + }); + + Assert.That(userConfig.CanUseReportType(ReportType.Connections), Is.False); + } + + [Test] + public void CanUseReportType_ExplicitVisibleShowsOtherwiseHiddenReportType() + { + UserConfig userConfig = BuildUserConfig(Roles.Modeller); + userConfig.ReportTypeVisibilityByRole = ReportTypeRoleVisibilityConfig.Serialize(new() + { + [Roles.Modeller] = new() { [ReportType.Rules] = ReportTypeVisibilityOption.Visible } + }); + + Assert.That(userConfig.CanUseReportType(ReportType.Rules), Is.True); + } + + [Test] + public void CanUseReportType_ExplicitVisibleStillHonoursModellingOwnerScoping() + { + UserConfig userConfig = BuildUserConfig(Roles.Modeller); + userConfig.ReportTypeVisibilityByRole = ReportTypeRoleVisibilityConfig.Serialize(new() + { + [Roles.Modeller] = new() { [ReportType.Connections] = ReportTypeVisibilityOption.Visible } + }); + + Assert.That(userConfig.CanUseReportType(ReportType.Connections, modellingOwnerAllowed: false), Is.False); + Assert.That(userConfig.CanUseReportType(ReportType.Connections, modellingOwnerAllowed: true), Is.True); + } + + [Test] + public void CanUseReportType_InheritedMatchesStandardCategoryRules() + { + UserConfig userConfig = BuildUserConfig(Roles.Modeller); + userConfig.AvailableReportTypes = System.Text.Json.JsonSerializer.Serialize(new List { ReportType.Rules, ReportType.Connections }); + userConfig.ReportTypeVisibilityByRole = ReportTypeRoleVisibilityConfig.Serialize(new() + { + [Roles.Modeller] = new() { [ReportType.Rules] = ReportTypeVisibilityOption.Inherited } + }); + + Assert.That(userConfig.CanUseReportType(ReportType.Rules), Is.False); + Assert.That(userConfig.CanUseReportType(ReportType.Connections), Is.True); + } + + [Test] + public void CanUseReportType_InheritedIsHiddenWhenNotGloballyAvailable() + { + UserConfig userConfig = BuildUserConfig(Roles.Modeller); + userConfig.AvailableReportTypes = System.Text.Json.JsonSerializer.Serialize(new List()); + + Assert.That(userConfig.CanUseReportType(ReportType.Connections), Is.False); + } + + [Test] + public void CanUseReportType_ExplicitVisibleOverridesGloballyDisabledReportType() + { + UserConfig userConfig = BuildUserConfig(Roles.Modeller); + userConfig.AvailableReportTypes = System.Text.Json.JsonSerializer.Serialize(new List()); + userConfig.ReportTypeVisibilityByRole = ReportTypeRoleVisibilityConfig.Serialize(new() + { + [Roles.Modeller] = new() { [ReportType.Connections] = ReportTypeVisibilityOption.Visible } + }); + + Assert.That(userConfig.CanUseReportType(ReportType.Connections), Is.True); + } + + [Test] + public void CanUseReportType_ExplicitNotVisibleOverridesGloballyEnabledReportType() + { + UserConfig userConfig = BuildUserConfig(Roles.Modeller); + userConfig.AvailableReportTypes = System.Text.Json.JsonSerializer.Serialize(new List { ReportType.Connections }); + userConfig.ReportTypeVisibilityByRole = ReportTypeRoleVisibilityConfig.Serialize(new() + { + [Roles.Modeller] = new() { [ReportType.Connections] = ReportTypeVisibilityOption.NotVisible } + }); + + Assert.That(userConfig.CanUseReportType(ReportType.Connections), Is.False); + } + + [Test] + public void GetExplicitlyDeniedRoles_ReturnsOnlyRolesSetToNotVisible() + { + UserConfig userConfig = BuildUserConfig(Roles.Modeller, Roles.Recertifier); + userConfig.ReportTypeVisibilityByRole = ReportTypeRoleVisibilityConfig.Serialize(new() + { + [Roles.Modeller] = new() { [ReportType.Rules] = ReportTypeVisibilityOption.NotVisible }, + [Roles.Recertifier] = new() { [ReportType.Rules] = ReportTypeVisibilityOption.Visible } + }); + + Assert.That(userConfig.GetExplicitlyDeniedRoles(ReportType.Rules), Is.EqualTo(new List { Roles.Modeller })); + } + private static UserConfig BuildUserConfig(params string[] roles) { UserConfig userConfig = new(); diff --git a/roles/tests-unit/files/FWO.Test/SimulatedUserConfig.cs b/roles/tests-unit/files/FWO.Test/SimulatedUserConfig.cs index 2b088a2c93..2ddac4fa8f 100644 --- a/roles/tests-unit/files/FWO.Test/SimulatedUserConfig.cs +++ b/roles/tests-unit/files/FWO.Test/SimulatedUserConfig.cs @@ -30,6 +30,14 @@ public SimulatedGlobalConfig() : base() internal class SimulatedUserConfig : UserConfig { + public SimulatedUserConfig() : base() + { + // Report-type tests generally exercise the per-role/category visibility rules, not the + // global on/off switch - default to every report type being globally available so those + // rules are the only thing narrowing CanUseReportType, unless a test overrides this itself. + AvailableReportTypes = System.Text.Json.JsonSerializer.Serialize(FWO.Basics.ReportTypeGroups.AllReportTypes()); + } + public static Dictionary DummyTranslate = new() { {"Rules","Rules Report"}, diff --git a/roles/ui/files/FWO.UI/Pages/Help/HelpSettingsReportGen.cshtml b/roles/ui/files/FWO.UI/Pages/Help/HelpSettingsReportGen.cshtml index e869cbb5ae..4a2950de89 100644 --- a/roles/ui/files/FWO.UI/Pages/Help/HelpSettingsReportGen.cshtml +++ b/roles/ui/files/FWO.UI/Pages/Help/HelpSettingsReportGen.cshtml @@ -24,5 +24,6 @@
  • @(Html.Raw(userConfig.GetText("H5447")))
  • @(Html.Raw(userConfig.GetText("H5448")))
  • @(Html.Raw(userConfig.GetText("H5455")))
  • +
  • @(Html.Raw(userConfig.GetText("H5460")))
  • diff --git a/roles/ui/files/FWO.UI/Pages/Reporting/Report.razor b/roles/ui/files/FWO.UI/Pages/Reporting/Report.razor index 393d0f1b88..5131dbe5b5 100644 --- a/roles/ui/files/FWO.UI/Pages/Reporting/Report.razor +++ b/roles/ui/files/FWO.UI/Pages/Reporting/Report.razor @@ -8,7 +8,6 @@ @using FWO.Ui.Pages.Reporting.Reports @using FWO.Services.RuleTreeBuilder @using System.Diagnostics -@using System.Text.Json @page "/report/generation/{appId?}" @attribute [Authorize(Roles = $"{Roles.Admin}, {Roles.FwAdmin}, {Roles.Reporter}, {Roles.ReporterViewAll}, {Roles.Auditor}, {Roles.Modeller}, {Roles.Recertifier}")] @@ -391,10 +390,13 @@ { try { + + // Build the chooser from the complete report-type set and let CanUseReportType (which combines + // global availability with the per-role tri-state) decide what is selectable - a report type + // removed from the global list must still be reachable via an explicit per-role "Visible" override. availableReportTypes = ReportTypeGroups.CustomSortReportType( - [.. (JsonSerializer.Deserialize>(string.IsNullOrEmpty(userConfig.AvailableReportTypes) ? "[]" : userConfig.AvailableReportTypes) - ?? throw new JsonException("Config data could not be parsed.")).Where(IsSelectableReportType)], - reportVisibility); + [.. ReportTypeGroups.AllReportTypes().Where(IsSelectableReportType)], + reportVisibility, filterByVisibility: false); int? parsedAppId = int.TryParse(AppId, out int appId) ? appId : null; if(parsedAppId != null) { @@ -410,7 +412,8 @@ InitDone = true; return; } - apiConnection.SetBestRoleForReport(authenticationStateTask!.Result.User, actReportFilters.ReportType); + apiConnection.SetBestRoleForReport(authenticationStateTask!.Result.User, actReportFilters.ReportType, + userConfig.GetExplicitlyDeniedRoles(actReportFilters.ReportType)); actReportFilters.DeviceFilter.Managements = await apiConnection.SendQueryAsync>(DeviceQueries.getDevicesByManagement); if(parsedAppId != null && reportVisibility.ModellingRelated) { @@ -453,7 +456,8 @@ UpdateRightSidebarVisibility(newReportType); actReportFilters.ReportType = newReportType; - apiConnection.SetBestRoleForReport(authenticationStateTask!.Result.User, actReportFilters.ReportType); + apiConnection.SetBestRoleForReport(authenticationStateTask!.Result.User, actReportFilters.ReportType, + userConfig.GetExplicitlyDeniedRoles(actReportFilters.ReportType)); actReportFilters.SetDisplayedTimeSelection(); AdjustDeviceSelection(); ResetReport(); @@ -621,7 +625,8 @@ try { processing = true; - apiConnection.SetBestRoleForReport(authenticationStateTask!.Result.User, actReportFilters.ReportType); + apiConnection.SetBestRoleForReport(authenticationStateTask!.Result.User, actReportFilters.ReportType, + userConfig.GetExplicitlyDeniedRoles(actReportFilters.ReportType)); await PrepareReportGeneration(); ReportTemplate temp = ConstructReportTemplate(); diff --git a/roles/ui/files/FWO.UI/Pages/Reporting/ReportExport.razor b/roles/ui/files/FWO.UI/Pages/Reporting/ReportExport.razor index 283fe6ac6b..2d76be62cd 100644 --- a/roles/ui/files/FWO.UI/Pages/Reporting/ReportExport.razor +++ b/roles/ui/files/FWO.UI/Pages/Reporting/ReportExport.razor @@ -279,7 +279,7 @@ { await FetchReportObjects(); return true; - }); + }, userConfig.GetExplicitlyDeniedRoles(ReportToExport.ReportType)); } /// diff --git a/roles/ui/files/FWO.UI/Pages/Settings/SettingsReportGeneral.razor b/roles/ui/files/FWO.UI/Pages/Settings/SettingsReportGeneral.razor index 959d19208e..42ffe80969 100644 --- a/roles/ui/files/FWO.UI/Pages/Settings/SettingsReportGeneral.razor +++ b/roles/ui/files/FWO.UI/Pages/Settings/SettingsReportGeneral.razor @@ -3,6 +3,7 @@ @using System.Text.Json.Serialization @using FWO.Api.Client.Queries @using FWO.Data.Report +@using FWO.Middleware.Client @using FWO.Ui.Services @using FWO.Basics @@ -12,6 +13,7 @@ @inject ApiConnection apiConnection @inject GlobalConfig globalConfig @inject UserConfig userConfig +@inject MiddlewareClient middlewareClient

    @(userConfig.GetText("report_settings"))

    @@ -89,7 +91,7 @@
    - @foreach (ReportType type in ReportTypeGroups.ReportTypeSelection().Except(new[] { ReportType.Undefined })) + @foreach (ReportType type in ReportTypeGroups.ReportTypeSelection().Except(ExcludedReportTypesForSelection)) {
    @@ -98,6 +100,58 @@ }
    +
    +
    + +
    + @if (availableRoles.Count > 0) + { +
    + +
    + +
    +
    + @if (!string.IsNullOrEmpty(selectedRole) && reportTypeVisibilityByRole.TryGetValue(selectedRole, out Dictionary? visibilityForRole)) + { + @foreach (ReportType type in ReportTypeGroups.ReportTypeSelection().Except(ExcludedReportTypesForSelection)) + { +
    + +
    +
    + + + + + + + + +
    +
    +
    + } + } + } + else + { + + } +
    +

    @@ -131,6 +185,20 @@ else private Dictionary reportTypesActiveDict = []; private List legacyReportSchedulerConfigs = []; private bool ShowMigrateSchedulerConfigDialog; + private List availableRoles = []; + private string selectedRole = ""; + private Dictionary> reportTypeVisibilityByRole = []; + + /// + /// The visibility overrides as last read from the database, keyed by every role that had an + /// override saved - not just the roles currently returned by LDAP. + /// only covers (which is empty if the LDAP lookup failed), so + /// merges into this instead of serializing directly - otherwise a + /// transient LDAP failure would wipe out all previously saved overrides on the next unrelated Save. + /// + private Dictionary> savedReportTypeVisibilityByRole = []; + + private static readonly ReportType[] ExcludedReportTypesForSelection = [ReportType.Undefined]; protected override async Task OnInitializedAsync() { @@ -146,6 +214,12 @@ else { reportTypesActiveDict.Add(type, availableReportTypes.Contains(type)); } + + availableRoles = (await RoleAccess.GetRolesFromInternalLdap(middlewareClient)).Select(r => r.Name) + .Where(n => !RoleGroups.IsTechnicalOrAnonymous(n)).OrderBy(s => s).ToList(); + selectedRole = ""; + savedReportTypeVisibilityByRole = ReportTypeRoleVisibilityConfig.Parse(configData.ReportTypeVisibilityByRole); + reportTypeVisibilityByRole = BuildReportTypeVisibilityByRole(availableRoles, savedReportTypeVisibilityByRole); } catch (Exception exception) { @@ -153,6 +227,24 @@ else } } + private static Dictionary> BuildReportTypeVisibilityByRole( + List roles, Dictionary> saved) + { + Dictionary> byRole = []; + foreach (string role in roles) + { + Dictionary byType = []; + saved.TryGetValue(role, out Dictionary? savedByType); + foreach (ReportType type in ReportTypeGroups.AllReportTypes()) + { + byType[type] = savedByType != null && savedByType.TryGetValue(type, out ReportTypeVisibilityOption option) + ? option : ReportTypeVisibilityOption.Inherited; + } + byRole[role] = byType; + } + return byRole; + } + private async Task Save() { try @@ -168,6 +260,11 @@ else } } configData.AvailableReportTypes = JsonSerializer.Serialize(availableReportTypes); + foreach (KeyValuePair> entry in reportTypeVisibilityByRole) + { + savedReportTypeVisibilityByRole[entry.Key] = entry.Value; + } + configData.ReportTypeVisibilityByRole = ReportTypeRoleVisibilityConfig.Serialize(savedReportTypeVisibilityByRole); await globalConfig.WriteToDatabase(configData, apiConnection); DisplayMessageInUi(null, userConfig.GetText("change_default"), userConfig.GetText("U5301"), false); diff --git a/roles/ui/files/FWO.UI/Shared/RightSidebar.razor b/roles/ui/files/FWO.UI/Shared/RightSidebar.razor index 3e0b05e8e1..e8f8f2d216 100644 --- a/roles/ui/files/FWO.UI/Shared/RightSidebar.razor +++ b/roles/ui/files/FWO.UI/Shared/RightSidebar.razor @@ -499,7 +499,7 @@ // for ReportAppRules we need to make sure we use the correct role (esp. modeller instead of reporter) AuthenticationState authenticationState = await authenticationStateTask; gotAllObjects = await apiConnection.RunWithBestRoleForReport(authenticationState.User, - CurrentReport!.ReportType, FetchReportObjects); + CurrentReport!.ReportType, FetchReportObjects, userConfig.GetExplicitlyDeniedRoles(CurrentReport!.ReportType)); } if (!gotAllObjects)