f480a56737
Closes the operator parity gap the deferred-work register tracked as #12: native alarm source overrides could only be set one-at-a-time, while attribute overrides had a CSV bulk path. Adds an all-or-nothing CSV import for native sources. - Commons: extract the RFC-4180 line splitter into a shared CsvLineSplitter (refactor OverrideCsvParser onto it — no behavior change, pinned by its tests); new NativeAlarmSourceOverrideCsvParser (header SourceName,Connection, SourceReference,Filter; blank = inherited). - Commons: NativeAlarmSourceOverrideEntry + bulk SetInstanceNativeAlarmSource- OverridesCommand (auto-registered via the reflection command registry). - ManagementService: Deployer-gated handler — flattens once, validates every source resolves + is unlocked + no duplicates up front, then upserts the whole batch under a single SaveChanges (true all-or-nothing txn). Added to the frozen authorization matrix; dispatch-coverage guard passes. - CLI: `instance native-alarm-source import --instance-id --file` (parity with `instance import-overrides`) + README. - Tests: native parser (Commons), CLI parse/entry mapping, 3 bulk-handler tests (happy path single-commit, locked-source reject, unresolved-source reject). Also corrects a stale XML-doc line in ScriptRuntimeContext (WaitForAttribute quality-gated mode is shipped, not "planned") and updates the deferred-work register: marks #7/#13/#15/#16/#20 verified-resolved, #12 as CLI/API-shipped with only the Central UI upload affordance still pending. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
210 lines
10 KiB
C#
210 lines
10 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Frozen authorization matrix (arch-review UA2). Every registered management command
|
|
/// MUST have an entry in <see cref="Expected"/> giving its required-roles set (any-of
|
|
/// semantics) or <c>null</c> when the command is available to any authenticated user.
|
|
/// A newly-added command with no entry fails <see cref="EveryRegisteredCommand_IsInTheFrozenTable"/>,
|
|
/// 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
|
|
/// <c>ManagementActor.GetRequiredRoles</c>; it is authored by hand from the switch and
|
|
/// cross-checked against <c>docs/requirements/Component-ManagementService.md</c>
|
|
/// §Authorization. Do not regenerate it mechanically from the source under test — that
|
|
/// would defeat the freeze.
|
|
/// </summary>
|
|
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<string, string[]?> 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],
|
|
["SetInstanceNativeAlarmSourceOverrides"] = [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<object[]> 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));
|
|
}
|
|
}
|
|
}
|