feat(auth)!: ScadaBridge canonical roles + SoD collapse (Audit→Administrator, AuditReadOnly→Viewer) + config-DB migration (Task 1.7)

Standardize role string VALUES on the canonical vocabulary
(Administrator/Designer/Deployer/Viewer; Operator/Engineer unused here):
  Admin        -> Administrator
  Design       -> Designer
  Deployment   -> Deployer
  Audit        -> Administrator   (COLLAPSE; accepted privilege escalation)
  AuditReadOnly-> Viewer          (COLLAPSE; keeps audit-read, no export)

SoD: OperationalAuditRoles = { Administrator, Viewer },
     AuditExportRoles      = { Administrator }
so Viewer reads the audit log + nav but cannot bulk-export, while
Administrator does both + holds the full admin surface (the documented,
accepted auditor/admin SoD collapse).

Atomic move across every enforcement site:
- Roles constants; AuthorizationPolicies (RequireClaim values + SoD arrays +
  honest XML-doc); RoleMapper Deployer check.
- ManagementActor.GetRequiredRole switch + the hard-coded site-scope
  admin-bypass (now Roles.Administrator at all 6 sites). Site-scoping logic
  is otherwise unchanged.
- DebugStreamHub Administrator/Deployer gates (Deployer kept case-sensitive).
- CentralUI BrowseService/BindingTester Designer guards; LdapMappingForm
  dropdown now offers canonical values (incl. Viewer).
- Config-DB seed (LdapGroupMappings Id 1-4) + EF migration CanonicalizeRoles:
  Id-keyed UpdateData for seed rows + idempotent raw catch-all UPDATEs for
  operator-added rows. Down is lossy on the collapse (documented in-file).
  No pending model changes.

Tests reworked to the collapsed model across Security/CentralUI/
ManagementService/ConfigurationDatabase/Integration suites, incl. explicit
Viewer-reads-not-exports and former-Audit-now-Administrator-escalation cases.

CHANGELOG: BREAKING security note documenting the canonicalization + SoD
collapse.
This commit is contained in:
Joseph Doherty
2026-06-02 08:00:47 -04:00
parent 6ae605160c
commit b104760b3a
52 changed files with 2388 additions and 402 deletions
@@ -18,44 +18,52 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
/// </para>
///
/// <para>
/// Default role → permission mapping (#23 M7-T15 / Bundle G):
/// Default role → permission mapping (#23 M7-T15 / Bundle G), post Task 1.7
/// canonicalization + SoD collapse:
/// <list type="table">
/// <listheader>
/// <term>Role</term>
/// <description>Policies granted</description>
/// </listheader>
/// <item>
/// <term><c>Admin</c></term>
/// <term><c>Administrator</c></term>
/// <description><see cref="RequireAdmin"/>,
/// <see cref="OperationalAudit"/>, <see cref="AuditExport"/> — admins hold
/// every permission by convention so an Admin-only user never loses
/// every permission by convention so an Administrator-only user never loses
/// access to a new surface.</description>
/// </item>
/// <item>
/// <term><c>Design</c></term>
/// <term><c>Designer</c></term>
/// <description><see cref="RequireDesign"/></description>
/// </item>
/// <item>
/// <term><c>Deployment</c></term>
/// <term><c>Deployer</c></term>
/// <description><see cref="RequireDeployment"/></description>
/// </item>
/// <item>
/// <term><c>Audit</c></term>
/// <description><see cref="OperationalAudit"/>,
/// <see cref="AuditExport"/> — the full audit surface (read + bulk
/// export) per <c>Component-AuditLog.md</c> §"Authorization".</description>
/// </item>
/// <item>
/// <term><c>AuditReadOnly</c></term>
/// <description><see cref="OperationalAudit"/> only — operators who
/// should see the Audit Log + drill in to incidents but not pull bulk
/// CSV exports. Use this when delegating triage without granting
/// forensic-export capability.</description>
/// <term><c>Viewer</c></term>
/// <description><see cref="OperationalAudit"/> only — read access to the
/// Audit Log + nav, but NOT <see cref="AuditExport"/>. This preserves the
/// half-SoD that the legacy <c>AuditReadOnly</c> role provided (read-not-
/// export) after <c>AuditReadOnly</c> was collapsed into
/// <c>Viewer</c>.</description>
/// </item>
/// </list>
/// <para>
/// SoD collapse (Task 1.7): the legacy distinct audit roles were removed. The
/// former <c>Audit</c> role (full audit surface = read + bulk export) was
/// collapsed into <c>Administrator</c> — a deliberate, accepted privilege
/// escalation (former audit-only users gain the entire admin surface: create
/// sites, manage LDAP mappings/API keys, import bundles). The former
/// <c>AuditReadOnly</c> role (read-only audit) was collapsed into
/// <c>Viewer</c>, which keeps audit-read but correctly LACKS export. The net
/// effect on the audit policies: <see cref="OperationalAudit"/> is granted to
/// {<c>Administrator</c>, <c>Viewer</c>} and <see cref="AuditExport"/> only to
/// {<c>Administrator</c>}.
/// </para>
/// LDAP group → role mapping is configured via the central UI Admin → LDAP
/// Mappings page (rows in <c>LdapGroupMappings</c>); the same code path
/// reads them whether the role is one of the four built-ins above or any
/// reads them whether the role is one of the built-ins above or any
/// future addition. Adding a role here means adding the LDAP mapping row in
/// the deployment; no schema migration is needed.
/// </para>
@@ -69,16 +77,16 @@ public static class AuthorizationPolicies
/// <summary>
/// Read access to the Audit Log #23 surface (Audit Log page,
/// Configuration Audit Log page, Audit nav group). Granted to the
/// <c>Audit</c> role, the <c>AuditReadOnly</c> role, and the
/// <c>Admin</c> role.
/// <c>Administrator</c> role and the <c>Viewer</c> role (the latter being
/// the post-Task-1.7 home of the former <c>AuditReadOnly</c> role).
/// </summary>
public const string OperationalAudit = "OperationalAudit";
/// <summary>
/// Permission to pull a bulk CSV export of the Audit Log. Separate from
/// <see cref="OperationalAudit"/> so a triage operator can read the
/// <see cref="OperationalAudit"/> so a <c>Viewer</c> can read the
/// table without being able to exfiltrate it in bulk. Granted to the
/// <c>Audit</c> role and the <c>Admin</c> role.
/// <c>Administrator</c> role only.
/// </summary>
public const string AuditExport = "AuditExport";
@@ -91,20 +99,23 @@ public static class AuthorizationPolicies
/// <c>/api/audit/*</c> routes with a manual Basic-Auth + LDAP role check
/// rather than the ASP.NET authorization-policy pipeline — can reuse the
/// exact same role set the <see cref="OperationalAudit"/> policy enforces.
/// Task 1.7: {<c>Administrator</c>, <c>Viewer</c>} (was {Admin, Audit,
/// AuditReadOnly} — the audit roles collapsed into Administrator/Viewer).
/// </remarks>
public static readonly string[] OperationalAuditRoles = { Roles.Admin, Roles.Audit, Roles.AuditReadOnly };
public static readonly string[] OperationalAuditRoles = { Roles.Administrator, Roles.Viewer };
/// <summary>
/// Roles that satisfy <see cref="AuditExport"/>. A strict subset of
/// <see cref="OperationalAuditRoles"/> — read access does NOT imply
/// export permission.
/// export permission, so <c>Viewer</c> can read but not export.
/// </summary>
/// <remarks>
/// Public for the same reason as <see cref="OperationalAuditRoles"/> —
/// the ManagementService <c>/api/audit/export</c> route checks roles
/// against this set directly.
/// against this set directly. Task 1.7: {<c>Administrator</c>} (was
/// {Admin, Audit}).
/// </remarks>
public static readonly string[] AuditExportRoles = { Roles.Admin, Roles.Audit };
public static readonly string[] AuditExportRoles = { Roles.Administrator };
/// <summary>
/// Registers the ScadaBridge authorization policies (Admin, Design, Deployment, OperationalAudit, AuditExport).
@@ -115,21 +126,21 @@ public static class AuthorizationPolicies
services.AddAuthorization(options =>
{
options.AddPolicy(RequireAdmin, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Admin));
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Administrator));
options.AddPolicy(RequireDesign, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Design));
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Designer));
options.AddPolicy(RequireDeployment, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Deployment));
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Deployer));
// Multi-role permission policies — the policy succeeds when the
// principal holds ANY of the mapped roles. RequireClaim with
// multiple allowed values is the right primitive: it checks
// whether *any* role claim's value is in the allowed set, so a
// user with role=Admin (and nothing else) satisfies the
// OperationalAudit policy without needing a separate Audit
// role claim.
// user with role=Administrator (and nothing else) satisfies the
// OperationalAudit policy, and a user with role=Viewer satisfies
// OperationalAudit but not AuditExport.
options.AddPolicy(OperationalAudit, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, OperationalAuditRoles));
@@ -39,7 +39,7 @@ public class RoleMapper
matchedRoles.Add(mapping.Role);
if (mapping.Role.Equals(Roles.Deployment, StringComparison.OrdinalIgnoreCase))
if (mapping.Role.Equals(Roles.Deployer, StringComparison.OrdinalIgnoreCase))
{
hasDeploymentRole = true;
+24 -5
View File
@@ -5,18 +5,37 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
/// Security module and downstream authorization checks.
/// </summary>
/// <remarks>
/// <para>
/// Role names appear in three independent contexts: <see cref="RoleMapper"/>
/// (LDAP-group → role resolution), <see cref="AuthorizationPolicies"/>
/// (policy <c>RequireClaim</c> values + the audit role arrays), and at LDAP
/// mapping rows configured by an operator. Holding the literals here means a
/// rename either succeeds everywhere or fails to compile, eliminating the
/// "string drift" class that Security-018 documented.
/// </para>
/// <para>
/// Task 1.7 canonicalization (auth normalization): role VALUES were
/// standardized onto the canonical six (<c>Viewer/Operator/Engineer/Designer/
/// Deployer/Administrator</c>; only four are used by ScadaBridge). The legacy
/// ScadaBridge role names were renamed/collapsed as follows:
/// <list type="bullet">
/// <item><description><c>Admin</c> → <c>Administrator</c></description></item>
/// <item><description><c>Design</c> → <c>Designer</c></description></item>
/// <item><description><c>Deployment</c> → <c>Deployer</c></description></item>
/// <item><description><c>Audit</c> → <c>Administrator</c> (COLLAPSE — accepted
/// separation-of-duties loss; a former audit-only user gains the full admin
/// surface)</description></item>
/// <item><description><c>AuditReadOnly</c> → <c>Viewer</c> (COLLAPSE — keeps
/// audit-read + nav, loses bulk export, which it never had)</description></item>
/// </list>
/// <c>Operator</c> and <c>Engineer</c> exist in the canonical vocabulary but are
/// unused by ScadaBridge, so they are intentionally not declared here.
/// </para>
/// </remarks>
public static class Roles
{
public const string Admin = "Admin";
public const string Design = "Design";
public const string Deployment = "Deployment";
public const string Audit = "Audit";
public const string AuditReadOnly = "AuditReadOnly";
public const string Administrator = "Administrator";
public const string Designer = "Designer";
public const string Deployer = "Deployer";
public const string Viewer = "Viewer";
}