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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user