diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs new file mode 100644 index 00000000..ba2305c4 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs @@ -0,0 +1,208 @@ +using System.Runtime.CompilerServices; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Security; + +namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; + +/// +/// Frozen authorization matrix (arch-review UA2). Every registered management command +/// MUST have an entry in giving its required-roles set (any-of +/// semantics) or null when the command is available to any authenticated user. +/// A newly-added command with no entry fails , +/// forcing a deliberate authorization decision instead of silently defaulting to the +/// read-only (any-authenticated) gate — the class of gap that produced C2/C4. +/// +/// The table reflects ACTUAL current behavior of +/// ManagementActor.GetRequiredRoles; it is authored by hand from the switch and +/// cross-checked against docs/requirements/Component-ManagementService.md +/// §Authorization. Do not regenerate it mechanically from the source under test — that +/// would defeat the freeze. +/// +public class RequiredRoleMatrixTests +{ + private static readonly string[] AreaManagers = [Roles.Designer, Roles.Deployer]; + private static readonly string[] SecuredWriteReaders = [Roles.Operator, Roles.Verifier, Roles.Administrator]; + + private static readonly Dictionary Expected = new(StringComparer.OrdinalIgnoreCase) + { + // ---- Administrator-only ----------------------------------------------------- + ["CreateSite"] = [Roles.Administrator], + ["UpdateSite"] = [Roles.Administrator], + ["DeleteSite"] = [Roles.Administrator], + ["ListRoleMappings"] = [Roles.Administrator], + ["CreateRoleMapping"] = [Roles.Administrator], + ["UpdateRoleMapping"] = [Roles.Administrator], + ["DeleteRoleMapping"] = [Roles.Administrator], + ["ListApiKeys"] = [Roles.Administrator], + ["CreateApiKey"] = [Roles.Administrator], + ["DeleteApiKey"] = [Roles.Administrator], + ["UpdateApiKey"] = [Roles.Administrator], + ["SetApiKeyMethods"] = [Roles.Administrator], + ["ListScopeRules"] = [Roles.Administrator], + ["AddScopeRule"] = [Roles.Administrator], + ["DeleteScopeRule"] = [Roles.Administrator], + ["QueryAuditLog"] = [Roles.Administrator], + ["UpdateSmtpConfig"] = [Roles.Administrator], + ["UpdateSmsConfig"] = [Roles.Administrator], + ["TrustServerCert"] = [Roles.Administrator], + ["RemoveServerCert"] = [Roles.Administrator], + ["ListServerCerts"] = [Roles.Administrator], + // Transport inbound bundle handling mutates cross-cutting config → Admin. + ["PreviewBundle"] = [Roles.Administrator], + ["ImportBundle"] = [Roles.Administrator], + + // ---- Area management: any-of [Designer, Deployer] --------------------------- + ["CreateArea"] = AreaManagers, + ["UpdateArea"] = AreaManagers, + ["DeleteArea"] = AreaManagers, + + // ---- Designer-only ---------------------------------------------------------- + ["CreateTemplate"] = [Roles.Designer], + ["UpdateTemplate"] = [Roles.Designer], + ["DeleteTemplate"] = [Roles.Designer], + ["ValidateTemplate"] = [Roles.Designer], + ["CreateExternalSystem"] = [Roles.Designer], + ["UpdateExternalSystem"] = [Roles.Designer], + ["DeleteExternalSystem"] = [Roles.Designer], + ["CreateExternalSystemMethod"] = [Roles.Designer], + ["UpdateExternalSystemMethod"] = [Roles.Designer], + ["DeleteExternalSystemMethod"] = [Roles.Designer], + ["CreateNotificationList"] = [Roles.Designer], + ["UpdateNotificationList"] = [Roles.Designer], + ["DeleteNotificationList"] = [Roles.Designer], + ["CreateDataConnection"] = [Roles.Designer], + ["UpdateDataConnection"] = [Roles.Designer], + ["DeleteDataConnection"] = [Roles.Designer], + ["MoveDataConnection"] = [Roles.Designer], + ["AddTemplateAttribute"] = [Roles.Designer], + ["UpdateTemplateAttribute"] = [Roles.Designer], + ["DeleteTemplateAttribute"] = [Roles.Designer], + ["AddTemplateAlarm"] = [Roles.Designer], + ["UpdateTemplateAlarm"] = [Roles.Designer], + ["DeleteTemplateAlarm"] = [Roles.Designer], + ["AddTemplateNativeAlarmSource"] = [Roles.Designer], + ["UpdateTemplateNativeAlarmSource"] = [Roles.Designer], + ["DeleteTemplateNativeAlarmSource"] = [Roles.Designer], + ["AddTemplateScript"] = [Roles.Designer], + ["UpdateTemplateScript"] = [Roles.Designer], + ["DeleteTemplateScript"] = [Roles.Designer], + ["AddTemplateComposition"] = [Roles.Designer], + ["DeleteTemplateComposition"] = [Roles.Designer], + ["ResyncInheritedMembers"] = [Roles.Designer], + ["CreateSharedScript"] = [Roles.Designer], + ["UpdateSharedScript"] = [Roles.Designer], + ["DeleteSharedScript"] = [Roles.Designer], + ["CreateSharedSchema"] = [Roles.Designer], + ["UpdateSharedSchema"] = [Roles.Designer], + ["DeleteSharedSchema"] = [Roles.Designer], + ["CreateDatabaseConnectionDef"] = [Roles.Designer], + ["UpdateDatabaseConnectionDef"] = [Roles.Designer], + ["DeleteDatabaseConnectionDef"] = [Roles.Designer], + ["CreateApiMethod"] = [Roles.Designer], + ["UpdateApiMethod"] = [Roles.Designer], + ["DeleteApiMethod"] = [Roles.Designer], + ["CreateTemplateFolder"] = [Roles.Designer], + ["RenameTemplateFolder"] = [Roles.Designer], + ["MoveTemplateFolder"] = [Roles.Designer], + ["ReorderTemplateFolder"] = [Roles.Designer], + ["DeleteTemplateFolder"] = [Roles.Designer], + ["MoveTemplateToFolder"] = [Roles.Designer], + ["BrowseNode"] = [Roles.Designer], + ["SearchAddressSpace"] = [Roles.Designer], + ["VerifyEndpoint"] = [Roles.Designer], + ["ExportBundle"] = [Roles.Designer], + + // ---- Deployer-only ---------------------------------------------------------- + ["CreateInstance"] = [Roles.Deployer], + ["MgmtDeployInstance"] = [Roles.Deployer], + ["MgmtEnableInstance"] = [Roles.Deployer], + ["MgmtDisableInstance"] = [Roles.Deployer], + ["MgmtDeleteInstance"] = [Roles.Deployer], + ["SetConnectionBindings"] = [Roles.Deployer], + ["SetInstanceOverrides"] = [Roles.Deployer], + ["SetInstanceArea"] = [Roles.Deployer], + ["SetInstanceAlarmOverride"] = [Roles.Deployer], + ["DeleteInstanceAlarmOverride"] = [Roles.Deployer], + ["SetInstanceNativeAlarmSourceOverride"] = [Roles.Deployer], + ["DeleteInstanceNativeAlarmSourceOverride"] = [Roles.Deployer], + ["GetDeploymentDiff"] = [Roles.Deployer], + ["MgmtDeployArtifacts"] = [Roles.Deployer], + ["QueryDeployments"] = [Roles.Deployer], + ["RetryParkedMessage"] = [Roles.Deployer], + ["DiscardParkedMessage"] = [Roles.Deployer], + ["DebugSnapshot"] = [Roles.Deployer], + + // ---- Two-person secured write ---------------------------------------------- + ["SubmitSecuredWrite"] = [Roles.Operator], + ["ApproveSecuredWrite"] = [Roles.Verifier], + ["RejectSecuredWrite"] = [Roles.Verifier], + ["ListSecuredWrites"] = SecuredWriteReaders, + + // ---- Read-only: any authenticated user (null gate) ------------------------- + ["ListTemplates"] = null, + ["GetTemplate"] = null, + ["GetResolvedTemplateMembers"] = null, + ["ListTemplateFolders"] = null, + ["ListTemplateNativeAlarmSources"] = null, + ["ListInstances"] = null, + ["GetInstance"] = null, + ["ListInstanceAlarmOverrides"] = null, + ["ListInstanceNativeAlarmSourceOverrides"] = null, + ["ListSites"] = null, + ["GetSite"] = null, + ["ListAreas"] = null, + ["ListDataConnections"] = null, + ["GetDataConnection"] = null, + ["ListExternalSystems"] = null, + ["GetExternalSystem"] = null, + ["ListExternalSystemMethods"] = null, + ["GetExternalSystemMethod"] = null, + ["ListNotificationLists"] = null, + ["GetNotificationList"] = null, + ["ListSmtpConfigs"] = null, + ["ListSmsConfigs"] = null, + ["ListSharedScripts"] = null, + ["GetSharedScript"] = null, + ["ListSharedSchemas"] = null, + ["GetSharedSchema"] = null, + ["ListDatabaseConnections"] = null, + ["GetDatabaseConnection"] = null, + ["ListApiMethods"] = null, + ["GetApiMethod"] = null, + ["GetHealthSummary"] = null, + ["GetSiteHealth"] = null, + ["QueryEventLogs"] = null, + ["QueryParkedMessages"] = null, + ["ReadTagValues"] = null, + // ResolveRoles is intentionally not dispatched (retired two-step flow); it + // falls through to the default null gate. Present here so the frozen table + // still covers the type reflected in the assembly. + ["ResolveRoles"] = null, + }; + + public static IEnumerable AllRegisteredCommands() + => typeof(ManagementEnvelope).Assembly.GetTypes() + .Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace + && t.Name.EndsWith("Command", StringComparison.Ordinal) && !t.IsAbstract) + .Select(t => new object[] { t }); + + [Theory, MemberData(nameof(AllRegisteredCommands))] + public void EveryRegisteredCommand_IsInTheFrozenTable(Type commandType) + { + var name = commandType.Name[..^"Command".Length]; + Assert.True(Expected.ContainsKey(name), + $"Command '{name}' has no entry in the frozen authorization table — add one deliberately."); + var instance = RuntimeHelpers.GetUninitializedObject(commandType); + var actual = ManagementActor.GetRequiredRoles(instance); + var expected = Expected[name]; + if (expected is null) + { + Assert.Null(actual); + } + else + { + Assert.NotNull(actual); + Assert.Equal(expected.OrderBy(x => x), actual!.OrderBy(x => x)); + } + } +}