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:
@@ -30,11 +30,12 @@
|
||||
<label class="form-label small">Role</label>
|
||||
<select class="form-select form-select-sm" @bind="_formRole">
|
||||
<option value="">Select role...</option>
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="Design">Design</option>
|
||||
<option value="Deployment">Deployment</option>
|
||||
<option value="@Roles.Administrator">Administrator</option>
|
||||
<option value="@Roles.Designer">Designer</option>
|
||||
<option value="@Roles.Deployer">Deployer</option>
|
||||
<option value="@Roles.Viewer">Viewer</option>
|
||||
</select>
|
||||
<div class="form-text">Deployment role: configure site scope below after saving.</div>
|
||||
<div class="form-text">Deployer role: configure site scope below after saving.</div>
|
||||
</div>
|
||||
@if (_formError != null)
|
||||
{
|
||||
|
||||
@@ -36,11 +36,11 @@ public sealed class BindingTester : IBindingTester
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// CentralUI-side role guard — sites don't enforce envelope-level
|
||||
// roles, so the Design check must happen here before any cross-cluster
|
||||
// roles, so the Designer check must happen here before any cross-cluster
|
||||
// traffic. Use HasClaim against JwtTokenService.RoleClaimType (not
|
||||
// IsInRole, per c1e16cf).
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design"))
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
|
||||
{
|
||||
return new ReadTagValuesResult(
|
||||
Array.Empty<TagReadOutcome>(),
|
||||
|
||||
@@ -43,9 +43,9 @@ public sealed class BrowseService : IBrowseService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// CentralUI-side role guard — sites don't enforce envelope-level roles,
|
||||
// so the Design check must happen here before any cross-cluster traffic.
|
||||
// so the Designer check must happen here before any cross-cluster traffic.
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design"))
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
|
||||
{
|
||||
return new BrowseNodeResult(
|
||||
Array.Empty<BrowseNode>(),
|
||||
|
||||
+8
-5
@@ -25,12 +25,15 @@ public class LdapGroupMappingConfiguration : IEntityTypeConfiguration<LdapGroupM
|
||||
|
||||
builder.HasIndex(m => m.LdapGroupName).IsUnique();
|
||||
|
||||
// Seed default group mappings matching GLAuth test users
|
||||
// Seed default group mappings matching GLAuth test users.
|
||||
// Role VALUES are the canonical six (Task 1.7): Administrator/Designer/
|
||||
// Deployer. The LDAP group NAMES (SCADA-Admins etc.) are unchanged —
|
||||
// only the role each group maps to was canonicalized.
|
||||
builder.HasData(
|
||||
new LdapGroupMapping("SCADA-Admins", "Admin") { Id = 1 },
|
||||
new LdapGroupMapping("SCADA-Designers", "Design") { Id = 2 },
|
||||
new LdapGroupMapping("SCADA-Deploy-All", "Deployment") { Id = 3 },
|
||||
new LdapGroupMapping("SCADA-Deploy-SiteA", "Deployment") { Id = 4 });
|
||||
new LdapGroupMapping("SCADA-Admins", "Administrator") { Id = 1 },
|
||||
new LdapGroupMapping("SCADA-Designers", "Designer") { Id = 2 },
|
||||
new LdapGroupMapping("SCADA-Deploy-All", "Deployer") { Id = 3 },
|
||||
new LdapGroupMapping("SCADA-Deploy-SiteA", "Deployer") { Id = 4 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1740
File diff suppressed because it is too large
Load Diff
+133
@@ -0,0 +1,133 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Task 1.7 — canonicalizes ScadaBridge role VALUES onto the canonical six
|
||||
/// (only Administrator/Designer/Deployer/Viewer are used here) and collapses
|
||||
/// the former audit roles:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Admin → Administrator</description></item>
|
||||
/// <item><description>Design → Designer</description></item>
|
||||
/// <item><description>Deployment → Deployer</description></item>
|
||||
/// <item><description>Audit → Administrator (COLLAPSE — accepted SoD loss /
|
||||
/// privilege escalation)</description></item>
|
||||
/// <item><description>AuditReadOnly → Viewer (COLLAPSE — keeps audit-read,
|
||||
/// never had export)</description></item>
|
||||
/// </list>
|
||||
/// The <c>UpdateData</c> calls below canonicalize the four seeded rows
|
||||
/// (Id 1-4). The raw <c>Sql</c> catch-alls then canonicalize ANY
|
||||
/// operator-added (non-seed) rows whose <c>Role</c> still holds a legacy
|
||||
/// value — <c>LdapGroupMappings.Role</c> is free-text nvarchar, so operators
|
||||
/// may have created rows with Admin/Design/Deployment/Audit/AuditReadOnly.
|
||||
/// The catch-alls are value-keyed (WHERE Role IN (...)), so they are
|
||||
/// idempotent and safe whether or not the seed <c>UpdateData</c> already ran
|
||||
/// (after the seed update the seed rows no longer match a legacy value, so
|
||||
/// they are simply skipped).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// LOSSY DOWN: the Audit/AuditReadOnly → Administrator/Viewer collapse is not
|
||||
/// perfectly reversible. Down is a best-effort that maps Administrator→Admin
|
||||
/// and Viewer→AuditReadOnly. It CANNOT recover the original Audit vs Admin
|
||||
/// distinction (both became Administrator) nor distinguish a genuine Viewer
|
||||
/// from a former AuditReadOnly (both became Viewer). The four seeded rows are
|
||||
/// restored exactly by the Id-keyed <c>UpdateData</c> calls; operator rows
|
||||
/// are reverted by the value-keyed catch-alls under this caveat.
|
||||
/// </remarks>
|
||||
public partial class CanonicalizeRoles : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 1) Canonicalize the four seeded rows by Id (EF-scaffolded).
|
||||
migrationBuilder.UpdateData(
|
||||
table: "LdapGroupMappings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "Role",
|
||||
value: "Administrator");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "LdapGroupMappings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "Role",
|
||||
value: "Designer");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "LdapGroupMappings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "Role",
|
||||
value: "Deployer");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "LdapGroupMappings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 4,
|
||||
column: "Role",
|
||||
value: "Deployer");
|
||||
|
||||
// 2) Catch-all for operator-added (non-seed) rows. Value-keyed and
|
||||
// idempotent. Audit collapses INTO Administrator; AuditReadOnly
|
||||
// collapses INTO Viewer.
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE [LdapGroupMappings] SET [Role] = N'Administrator' WHERE [Role] IN (N'Admin', N'Audit');");
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE [LdapGroupMappings] SET [Role] = N'Designer' WHERE [Role] = N'Design';");
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE [LdapGroupMappings] SET [Role] = N'Deployer' WHERE [Role] = N'Deployment';");
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE [LdapGroupMappings] SET [Role] = N'Viewer' WHERE [Role] = N'AuditReadOnly';");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Restore the four seeded rows exactly (Id-keyed). Down is lossy for
|
||||
// the collapsed audit roles — see the class <remarks>.
|
||||
migrationBuilder.UpdateData(
|
||||
table: "LdapGroupMappings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "Role",
|
||||
value: "Admin");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "LdapGroupMappings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "Role",
|
||||
value: "Design");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "LdapGroupMappings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "Role",
|
||||
value: "Deployment");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "LdapGroupMappings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 4,
|
||||
column: "Role",
|
||||
value: "Deployment");
|
||||
|
||||
// Best-effort reverse for operator-added (non-seed) rows. LOSSY:
|
||||
// Administrator→Admin and Viewer→AuditReadOnly cannot recover the
|
||||
// original Audit/Admin or Viewer/AuditReadOnly distinction (the
|
||||
// forward collapse merged those). Value-keyed and idempotent.
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE [LdapGroupMappings] SET [Role] = N'Admin' WHERE [Role] = N'Administrator';");
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE [LdapGroupMappings] SET [Role] = N'Design' WHERE [Role] = N'Designer';");
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE [LdapGroupMappings] SET [Role] = N'Deployment' WHERE [Role] = N'Deployer';");
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE [LdapGroupMappings] SET [Role] = N'AuditReadOnly' WHERE [Role] = N'Viewer';");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1045,25 +1045,25 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
LdapGroupName = "SCADA-Admins",
|
||||
Role = "Admin"
|
||||
Role = "Administrator"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
LdapGroupName = "SCADA-Designers",
|
||||
Role = "Design"
|
||||
Role = "Designer"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
LdapGroupName = "SCADA-Deploy-All",
|
||||
Role = "Deployment"
|
||||
Role = "Deployer"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
LdapGroupName = "SCADA-Deploy-SiteA",
|
||||
Role = "Deployment"
|
||||
Role = "Deployer"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -402,10 +402,11 @@ public static class AuditEndpoints
|
||||
/// </returns>
|
||||
public static AuditLogQueryFilter? ApplySiteScope(AuditLogQueryFilter filter, AuthenticatedUser user)
|
||||
{
|
||||
// Empty PermittedSiteIds is the system-wide signal (Admin, system-wide
|
||||
// Deployment). System-wide audit roles also fall here — the design treats
|
||||
// Audit/AuditReadOnly as non-site-scoped unless an operator attaches scope
|
||||
// rules to the LDAP mapping; if they do, this helper enforces them.
|
||||
// Empty PermittedSiteIds is the system-wide signal (Administrator,
|
||||
// system-wide Deployer). System-wide audit roles also fall here — the
|
||||
// design treats Administrator/Viewer (the post-Task-1.7 audit roles) as
|
||||
// non-site-scoped unless an operator attaches scope rules to the LDAP
|
||||
// mapping; if they do, this helper enforces them.
|
||||
if (user.PermittedSiteIds.Length == 0)
|
||||
{
|
||||
return filter;
|
||||
|
||||
@@ -26,8 +26,8 @@ public class DebugStreamHub : Hub
|
||||
/// Pure site-scope authorization check for a debug-stream subscription.
|
||||
/// Returns true when the caller may subscribe to a debug stream for an instance
|
||||
/// belonging to <paramref name="instanceSiteId"/>.
|
||||
/// Admin role, or an empty <paramref name="permittedSiteIds"/> (system-wide
|
||||
/// Deployment), grants access to any site; otherwise the instance's site must be
|
||||
/// Administrator role, or an empty <paramref name="permittedSiteIds"/> (system-wide
|
||||
/// Deployer), grants access to any site; otherwise the instance's site must be
|
||||
/// in the permitted set.
|
||||
/// </summary>
|
||||
/// <param name="roles">Roles held by the connected user.</param>
|
||||
@@ -38,7 +38,7 @@ public class DebugStreamHub : Hub
|
||||
IReadOnlyCollection<string> permittedSiteIds,
|
||||
int instanceSiteId)
|
||||
{
|
||||
if (roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) return true;
|
||||
if (roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return true;
|
||||
if (permittedSiteIds.Count == 0) return true; // system-wide deployment
|
||||
return permittedSiteIds.Contains(instanceSiteId.ToString());
|
||||
}
|
||||
@@ -109,13 +109,13 @@ public class DebugStreamHub : Hub
|
||||
return;
|
||||
}
|
||||
|
||||
// Role check — Deployment role required
|
||||
// Role check — Deployer role required
|
||||
var roleMapper = httpContext.RequestServices.GetRequiredService<RoleMapper>();
|
||||
var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, Context.ConnectionAborted);
|
||||
|
||||
if (!mappingResult.Roles.Contains("Deployment"))
|
||||
if (!mappingResult.Roles.Contains(Roles.Deployer))
|
||||
{
|
||||
_logger.LogWarning("DebugStreamHub connection rejected: {Username} lacks Deployment role", username);
|
||||
_logger.LogWarning("DebugStreamHub connection rejected: {Username} lacks Deployer role", username);
|
||||
Context.Abort();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ public class ManagementActor : ReceiveActor
|
||||
|
||||
private static string? GetRequiredRole(object command) => command switch
|
||||
{
|
||||
// Admin operations
|
||||
// Administrator operations
|
||||
CreateSiteCommand or UpdateSiteCommand or DeleteSiteCommand
|
||||
or ListRoleMappingsCommand or CreateRoleMappingCommand
|
||||
or UpdateRoleMappingCommand or DeleteRoleMappingCommand
|
||||
@@ -167,9 +167,9 @@ public class ManagementActor : ReceiveActor
|
||||
// should use the REST endpoint; this command is retained for
|
||||
// backward compatibility with the CentralUI Configuration Audit
|
||||
// Log page (Management-018).
|
||||
or QueryAuditLogCommand => "Admin",
|
||||
or QueryAuditLogCommand => Roles.Administrator,
|
||||
|
||||
// Design operations
|
||||
// Designer operations
|
||||
CreateAreaCommand or DeleteAreaCommand
|
||||
or CreateTemplateCommand or UpdateTemplateCommand or DeleteTemplateCommand
|
||||
or ValidateTemplateCommand
|
||||
@@ -194,14 +194,15 @@ public class ManagementActor : ReceiveActor
|
||||
or CreateTemplateFolderCommand or RenameTemplateFolderCommand
|
||||
or MoveTemplateFolderCommand or DeleteTemplateFolderCommand
|
||||
or MoveTemplateToFolderCommand
|
||||
or ExportBundleCommand => "Design",
|
||||
or ExportBundleCommand => Roles.Designer,
|
||||
|
||||
// Transport import operations (mirror the Central UI gating: Admin
|
||||
// for inbound bundle handling because they mutate cross-cutting
|
||||
// configuration; Export stays Design because it only reads).
|
||||
PreviewBundleCommand or ImportBundleCommand => "Admin",
|
||||
// Transport import operations (mirror the Central UI gating:
|
||||
// Administrator for inbound bundle handling because they mutate
|
||||
// cross-cutting configuration; Export stays Designer because it only
|
||||
// reads).
|
||||
PreviewBundleCommand or ImportBundleCommand => Roles.Administrator,
|
||||
|
||||
// Deployment operations
|
||||
// Deployer operations
|
||||
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand
|
||||
or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand
|
||||
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
|
||||
@@ -211,7 +212,7 @@ public class ManagementActor : ReceiveActor
|
||||
or MgmtDeployArtifactsCommand
|
||||
or QueryDeploymentsCommand
|
||||
or RetryParkedMessageCommand or DiscardParkedMessageCommand
|
||||
or DebugSnapshotCommand => "Deployment",
|
||||
or DebugSnapshotCommand => Roles.Deployer,
|
||||
|
||||
// Read-only queries -- any authenticated user
|
||||
_ => null
|
||||
@@ -385,15 +386,15 @@ public class ManagementActor : ReceiveActor
|
||||
// ========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Throws SiteScopeViolationException if the user has site-scoped Deployment
|
||||
/// Throws SiteScopeViolationException if the user has site-scoped Deployer
|
||||
/// and the target site is not in their permitted sites.
|
||||
/// Users with Admin or Design roles, or system-wide Deployment, are not restricted.
|
||||
/// Users with the Administrator role, or system-wide Deployer, are not restricted.
|
||||
/// </summary>
|
||||
private static void EnforceSiteScope(AuthenticatedUser user, int? targetSiteId)
|
||||
{
|
||||
if (targetSiteId == null) return;
|
||||
if (user.PermittedSiteIds.Length == 0) return; // system-wide access
|
||||
if (user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) return;
|
||||
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return;
|
||||
|
||||
if (!user.PermittedSiteIds.Contains(targetSiteId.Value.ToString()))
|
||||
{
|
||||
@@ -409,7 +410,7 @@ public class ManagementActor : ReceiveActor
|
||||
private static async Task EnforceSiteScopeForInstance(IServiceProvider sp, AuthenticatedUser user, int instanceId)
|
||||
{
|
||||
if (user.PermittedSiteIds.Length == 0) return;
|
||||
if (user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) return;
|
||||
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return;
|
||||
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
var instance = await repo.GetInstanceByIdAsync(instanceId);
|
||||
@@ -424,7 +425,7 @@ public class ManagementActor : ReceiveActor
|
||||
private static async Task EnforceSiteScopeForIdentifier(IServiceProvider sp, AuthenticatedUser user, string siteIdentifier)
|
||||
{
|
||||
if (user.PermittedSiteIds.Length == 0) return;
|
||||
if (user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) return;
|
||||
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return;
|
||||
|
||||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||||
var site = await repo.GetSiteByIdentifierAsync(siteIdentifier);
|
||||
@@ -617,7 +618,7 @@ public class ManagementActor : ReceiveActor
|
||||
var repo = sp.GetRequiredService<ICentralUiRepository>();
|
||||
var instances = await repo.GetInstancesFilteredAsync(cmd.SiteId, cmd.TemplateId, cmd.SearchTerm);
|
||||
// Filter by permitted sites for site-scoped users
|
||||
if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase))
|
||||
if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
|
||||
instances = instances.Where(i => permittedIds.Contains(i.SiteId.ToString())).ToList();
|
||||
@@ -864,7 +865,7 @@ public class ManagementActor : ReceiveActor
|
||||
{
|
||||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||||
var sites = await repo.GetAllSitesAsync();
|
||||
if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase))
|
||||
if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
|
||||
sites = sites.Where(s => permittedIds.Contains(s.Id.ToString())).ToList();
|
||||
@@ -1372,7 +1373,7 @@ public class ManagementActor : ReceiveActor
|
||||
// SiteId, so resolve each record's instance to its site and filter
|
||||
// (mirrors the HandleListInstances / HandleListSites filter pattern).
|
||||
var records = await repo.GetAllDeploymentRecordsAsync();
|
||||
if (user.PermittedSiteIds.Length == 0 || user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase))
|
||||
if (user.PermittedSiteIds.Length == 0 || user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
|
||||
return records;
|
||||
|
||||
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user