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
+47
View File
@@ -6,6 +6,53 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
### Changed — BREAKING: canonical role names + audit separation-of-duties collapse (Task 1.7)
Role string VALUES are standardized onto the canonical vocabulary
(`Administrator`/`Designer`/`Deployer`/`Viewer`; `Operator`/`Engineer` are unused
by ScadaBridge). The legacy ScadaBridge role names were renamed and two were
**collapsed**:
| Legacy role | Canonical role | Notes |
|-----------------|-----------------|-------|
| `Admin` | `Administrator` | rename |
| `Design` | `Designer` | rename |
| `Deployment` | `Deployer` | rename |
| `Audit` | `Administrator` | **COLLAPSE** |
| `AuditReadOnly` | `Viewer` | **COLLAPSE** |
- **SECURITY — privilege escalation (accepted).** The former `Audit` role
collapses into `Administrator`. This is a real escalation: a former audit-only
user now holds the **entire admin surface** (create/update/delete sites, manage
LDAP group→role mappings and API keys, preview/import transport bundles), not
just audit read+export. This loss of auditor/admin separation-of-duties is a
deliberate, accepted trade-off of the canonicalization.
- **SECURITY — half-SoD preserved.** The former `AuditReadOnly` role collapses
into `Viewer`, which **keeps audit READ** (Audit Log page, Configuration Audit
Log page, audit nav group) but **cannot bulk-export**. The audit policy sets are
now `OperationalAuditRoles = { Administrator, Viewer }` and
`AuditExportRoles = { Administrator }`, so a `Viewer` reads the audit log but the
Export-CSV button / `/api/audit/export` endpoint correctly refuses it.
- **Enforcement.** Every enforcement site moved together: the role-claim values,
the authorization policies (`RequireAdmin`/`RequireDesign`/`RequireDeployment`
policy *names* are unchanged; only the role *values* inside them changed), the
`ManagementActor.GetRequiredRole` switch, the hard-coded site-scope admin-bypass
(`Roles.Administrator` everywhere), the `DebugStreamHub` Administrator/Deployer
gates, and the CentralUI `BrowseService`/`BindingTester` Designer guards.
**Site-scoping logic is otherwise unchanged** — only the admin-bypass *value*
moved from `"Admin"` to `Roles.Administrator`.
- **Config-DB migration `CanonicalizeRoles`.** Updates the four seeded
`LdapGroupMappings` rows (Id 1-4) to the canonical role values and adds raw
idempotent catch-all `UPDATE`s for operator-added rows
(`Admin`/`Audit``Administrator`, `Design``Designer`, `Deployment``Deployer`,
`AuditReadOnly``Viewer`). The Down migration is **lossy** for the collapse: it
best-effort maps `Administrator``Admin` and `Viewer``AuditReadOnly` but cannot
recover the original `Audit`/`Admin` or `Viewer`/`AuditReadOnly` distinction.
- **Operator action.** Any LDAP group→role mappings created with the legacy role
strings are migrated automatically by `CanonicalizeRoles`. New mappings created
via the CentralUI LDAP-mappings form now offer the canonical role values
(including a `Viewer` option for audit-read-only delegation).
### Changed — BREAKING: inbound API authentication ### Changed — BREAKING: inbound API authentication
Inbound API authentication has migrated off the SQL Server `X-API-Key` scheme and Inbound API authentication has migrated off the SQL Server `X-API-Key` scheme and
@@ -30,11 +30,12 @@
<label class="form-label small">Role</label> <label class="form-label small">Role</label>
<select class="form-select form-select-sm" @bind="_formRole"> <select class="form-select form-select-sm" @bind="_formRole">
<option value="">Select role...</option> <option value="">Select role...</option>
<option value="Admin">Admin</option> <option value="@Roles.Administrator">Administrator</option>
<option value="Design">Design</option> <option value="@Roles.Designer">Designer</option>
<option value="Deployment">Deployment</option> <option value="@Roles.Deployer">Deployer</option>
<option value="@Roles.Viewer">Viewer</option>
</select> </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> </div>
@if (_formError != null) @if (_formError != null)
{ {
@@ -36,11 +36,11 @@ public sealed class BindingTester : IBindingTester
CancellationToken ct = default) CancellationToken ct = default)
{ {
// CentralUI-side role guard — sites don't enforce envelope-level // 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 // traffic. Use HasClaim against JwtTokenService.RoleClaimType (not
// IsInRole, per c1e16cf). // IsInRole, per c1e16cf).
var state = await _auth.GetAuthenticationStateAsync(); var state = await _auth.GetAuthenticationStateAsync();
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design")) if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
{ {
return new ReadTagValuesResult( return new ReadTagValuesResult(
Array.Empty<TagReadOutcome>(), Array.Empty<TagReadOutcome>(),
@@ -43,9 +43,9 @@ public sealed class BrowseService : IBrowseService
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
// CentralUI-side role guard — sites don't enforce envelope-level roles, // 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(); var state = await _auth.GetAuthenticationStateAsync();
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design")) if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
{ {
return new BrowseNodeResult( return new BrowseNodeResult(
Array.Empty<BrowseNode>(), Array.Empty<BrowseNode>(),
@@ -25,12 +25,15 @@ public class LdapGroupMappingConfiguration : IEntityTypeConfiguration<LdapGroupM
builder.HasIndex(m => m.LdapGroupName).IsUnique(); 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( builder.HasData(
new LdapGroupMapping("SCADA-Admins", "Admin") { Id = 1 }, new LdapGroupMapping("SCADA-Admins", "Administrator") { Id = 1 },
new LdapGroupMapping("SCADA-Designers", "Design") { Id = 2 }, new LdapGroupMapping("SCADA-Designers", "Designer") { Id = 2 },
new LdapGroupMapping("SCADA-Deploy-All", "Deployment") { Id = 3 }, new LdapGroupMapping("SCADA-Deploy-All", "Deployer") { Id = 3 },
new LdapGroupMapping("SCADA-Deploy-SiteA", "Deployment") { Id = 4 }); new LdapGroupMapping("SCADA-Deploy-SiteA", "Deployer") { Id = 4 });
} }
} }
@@ -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';");
}
}
}
@@ -1045,25 +1045,25 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
{ {
Id = 1, Id = 1,
LdapGroupName = "SCADA-Admins", LdapGroupName = "SCADA-Admins",
Role = "Admin" Role = "Administrator"
}, },
new new
{ {
Id = 2, Id = 2,
LdapGroupName = "SCADA-Designers", LdapGroupName = "SCADA-Designers",
Role = "Design" Role = "Designer"
}, },
new new
{ {
Id = 3, Id = 3,
LdapGroupName = "SCADA-Deploy-All", LdapGroupName = "SCADA-Deploy-All",
Role = "Deployment" Role = "Deployer"
}, },
new new
{ {
Id = 4, Id = 4,
LdapGroupName = "SCADA-Deploy-SiteA", LdapGroupName = "SCADA-Deploy-SiteA",
Role = "Deployment" Role = "Deployer"
}); });
}); });
@@ -402,10 +402,11 @@ public static class AuditEndpoints
/// </returns> /// </returns>
public static AuditLogQueryFilter? ApplySiteScope(AuditLogQueryFilter filter, AuthenticatedUser user) public static AuditLogQueryFilter? ApplySiteScope(AuditLogQueryFilter filter, AuthenticatedUser user)
{ {
// Empty PermittedSiteIds is the system-wide signal (Admin, system-wide // Empty PermittedSiteIds is the system-wide signal (Administrator,
// Deployment). System-wide audit roles also fall here — the design treats // system-wide Deployer). System-wide audit roles also fall here — the
// Audit/AuditReadOnly as non-site-scoped unless an operator attaches scope // design treats Administrator/Viewer (the post-Task-1.7 audit roles) as
// rules to the LDAP mapping; if they do, this helper enforces them. // 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) if (user.PermittedSiteIds.Length == 0)
{ {
return filter; return filter;
@@ -26,8 +26,8 @@ public class DebugStreamHub : Hub
/// Pure site-scope authorization check for a debug-stream subscription. /// Pure site-scope authorization check for a debug-stream subscription.
/// Returns true when the caller may subscribe to a debug stream for an instance /// Returns true when the caller may subscribe to a debug stream for an instance
/// belonging to <paramref name="instanceSiteId"/>. /// belonging to <paramref name="instanceSiteId"/>.
/// Admin role, or an empty <paramref name="permittedSiteIds"/> (system-wide /// Administrator role, or an empty <paramref name="permittedSiteIds"/> (system-wide
/// Deployment), grants access to any site; otherwise the instance's site must be /// Deployer), grants access to any site; otherwise the instance's site must be
/// in the permitted set. /// in the permitted set.
/// </summary> /// </summary>
/// <param name="roles">Roles held by the connected user.</param> /// <param name="roles">Roles held by the connected user.</param>
@@ -38,7 +38,7 @@ public class DebugStreamHub : Hub
IReadOnlyCollection<string> permittedSiteIds, IReadOnlyCollection<string> permittedSiteIds,
int instanceSiteId) 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 if (permittedSiteIds.Count == 0) return true; // system-wide deployment
return permittedSiteIds.Contains(instanceSiteId.ToString()); return permittedSiteIds.Contains(instanceSiteId.ToString());
} }
@@ -109,13 +109,13 @@ public class DebugStreamHub : Hub
return; return;
} }
// Role check — Deployment role required // Role check — Deployer role required
var roleMapper = httpContext.RequestServices.GetRequiredService<RoleMapper>(); var roleMapper = httpContext.RequestServices.GetRequiredService<RoleMapper>();
var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, Context.ConnectionAborted); 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(); Context.Abort();
return; return;
} }
@@ -153,7 +153,7 @@ public class ManagementActor : ReceiveActor
private static string? GetRequiredRole(object command) => command switch private static string? GetRequiredRole(object command) => command switch
{ {
// Admin operations // Administrator operations
CreateSiteCommand or UpdateSiteCommand or DeleteSiteCommand CreateSiteCommand or UpdateSiteCommand or DeleteSiteCommand
or ListRoleMappingsCommand or CreateRoleMappingCommand or ListRoleMappingsCommand or CreateRoleMappingCommand
or UpdateRoleMappingCommand or DeleteRoleMappingCommand or UpdateRoleMappingCommand or DeleteRoleMappingCommand
@@ -167,9 +167,9 @@ public class ManagementActor : ReceiveActor
// should use the REST endpoint; this command is retained for // should use the REST endpoint; this command is retained for
// backward compatibility with the CentralUI Configuration Audit // backward compatibility with the CentralUI Configuration Audit
// Log page (Management-018). // Log page (Management-018).
or QueryAuditLogCommand => "Admin", or QueryAuditLogCommand => Roles.Administrator,
// Design operations // Designer operations
CreateAreaCommand or DeleteAreaCommand CreateAreaCommand or DeleteAreaCommand
or CreateTemplateCommand or UpdateTemplateCommand or DeleteTemplateCommand or CreateTemplateCommand or UpdateTemplateCommand or DeleteTemplateCommand
or ValidateTemplateCommand or ValidateTemplateCommand
@@ -194,14 +194,15 @@ public class ManagementActor : ReceiveActor
or CreateTemplateFolderCommand or RenameTemplateFolderCommand or CreateTemplateFolderCommand or RenameTemplateFolderCommand
or MoveTemplateFolderCommand or DeleteTemplateFolderCommand or MoveTemplateFolderCommand or DeleteTemplateFolderCommand
or MoveTemplateToFolderCommand or MoveTemplateToFolderCommand
or ExportBundleCommand => "Design", or ExportBundleCommand => Roles.Designer,
// Transport import operations (mirror the Central UI gating: Admin // Transport import operations (mirror the Central UI gating:
// for inbound bundle handling because they mutate cross-cutting // Administrator for inbound bundle handling because they mutate
// configuration; Export stays Design because it only reads). // cross-cutting configuration; Export stays Designer because it only
PreviewBundleCommand or ImportBundleCommand => "Admin", // reads).
PreviewBundleCommand or ImportBundleCommand => Roles.Administrator,
// Deployment operations // Deployer operations
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand
or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
@@ -211,7 +212,7 @@ public class ManagementActor : ReceiveActor
or MgmtDeployArtifactsCommand or MgmtDeployArtifactsCommand
or QueryDeploymentsCommand or QueryDeploymentsCommand
or RetryParkedMessageCommand or DiscardParkedMessageCommand or RetryParkedMessageCommand or DiscardParkedMessageCommand
or DebugSnapshotCommand => "Deployment", or DebugSnapshotCommand => Roles.Deployer,
// Read-only queries -- any authenticated user // Read-only queries -- any authenticated user
_ => null _ => null
@@ -385,15 +386,15 @@ public class ManagementActor : ReceiveActor
// ======================================================================== // ========================================================================
/// <summary> /// <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. /// 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> /// </summary>
private static void EnforceSiteScope(AuthenticatedUser user, int? targetSiteId) private static void EnforceSiteScope(AuthenticatedUser user, int? targetSiteId)
{ {
if (targetSiteId == null) return; if (targetSiteId == null) return;
if (user.PermittedSiteIds.Length == 0) return; // system-wide access 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())) 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) private static async Task EnforceSiteScopeForInstance(IServiceProvider sp, AuthenticatedUser user, int instanceId)
{ {
if (user.PermittedSiteIds.Length == 0) return; 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 repo = sp.GetRequiredService<ITemplateEngineRepository>();
var instance = await repo.GetInstanceByIdAsync(instanceId); 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) private static async Task EnforceSiteScopeForIdentifier(IServiceProvider sp, AuthenticatedUser user, string siteIdentifier)
{ {
if (user.PermittedSiteIds.Length == 0) return; 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 repo = sp.GetRequiredService<ISiteRepository>();
var site = await repo.GetSiteByIdentifierAsync(siteIdentifier); var site = await repo.GetSiteByIdentifierAsync(siteIdentifier);
@@ -617,7 +618,7 @@ public class ManagementActor : ReceiveActor
var repo = sp.GetRequiredService<ICentralUiRepository>(); var repo = sp.GetRequiredService<ICentralUiRepository>();
var instances = await repo.GetInstancesFilteredAsync(cmd.SiteId, cmd.TemplateId, cmd.SearchTerm); var instances = await repo.GetInstancesFilteredAsync(cmd.SiteId, cmd.TemplateId, cmd.SearchTerm);
// Filter by permitted sites for site-scoped users // 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); var permittedIds = new HashSet<string>(user.PermittedSiteIds);
instances = instances.Where(i => permittedIds.Contains(i.SiteId.ToString())).ToList(); instances = instances.Where(i => permittedIds.Contains(i.SiteId.ToString())).ToList();
@@ -864,7 +865,7 @@ public class ManagementActor : ReceiveActor
{ {
var repo = sp.GetRequiredService<ISiteRepository>(); var repo = sp.GetRequiredService<ISiteRepository>();
var sites = await repo.GetAllSitesAsync(); 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); var permittedIds = new HashSet<string>(user.PermittedSiteIds);
sites = sites.Where(s => permittedIds.Contains(s.Id.ToString())).ToList(); 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 // SiteId, so resolve each record's instance to its site and filter
// (mirrors the HandleListInstances / HandleListSites filter pattern). // (mirrors the HandleListInstances / HandleListSites filter pattern).
var records = await repo.GetAllDeploymentRecordsAsync(); 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; return records;
var permittedIds = new HashSet<string>(user.PermittedSiteIds); var permittedIds = new HashSet<string>(user.PermittedSiteIds);
@@ -18,44 +18,52 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
/// </para> /// </para>
/// ///
/// <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"> /// <list type="table">
/// <listheader> /// <listheader>
/// <term>Role</term> /// <term>Role</term>
/// <description>Policies granted</description> /// <description>Policies granted</description>
/// </listheader> /// </listheader>
/// <item> /// <item>
/// <term><c>Admin</c></term> /// <term><c>Administrator</c></term>
/// <description><see cref="RequireAdmin"/>, /// <description><see cref="RequireAdmin"/>,
/// <see cref="OperationalAudit"/>, <see cref="AuditExport"/> — admins hold /// <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> /// access to a new surface.</description>
/// </item> /// </item>
/// <item> /// <item>
/// <term><c>Design</c></term> /// <term><c>Designer</c></term>
/// <description><see cref="RequireDesign"/></description> /// <description><see cref="RequireDesign"/></description>
/// </item> /// </item>
/// <item> /// <item>
/// <term><c>Deployment</c></term> /// <term><c>Deployer</c></term>
/// <description><see cref="RequireDeployment"/></description> /// <description><see cref="RequireDeployment"/></description>
/// </item> /// </item>
/// <item> /// <item>
/// <term><c>Audit</c></term> /// <term><c>Viewer</c></term>
/// <description><see cref="OperationalAudit"/>, /// <description><see cref="OperationalAudit"/> only — read access to the
/// <see cref="AuditExport"/> — the full audit surface (read + bulk /// Audit Log + nav, but NOT <see cref="AuditExport"/>. This preserves the
/// export) per <c>Component-AuditLog.md</c> §"Authorization".</description> /// half-SoD that the legacy <c>AuditReadOnly</c> role provided (read-not-
/// </item> /// export) after <c>AuditReadOnly</c> was collapsed into
/// <item> /// <c>Viewer</c>.</description>
/// <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>
/// </item> /// </item>
/// </list> /// </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 /// LDAP group → role mapping is configured via the central UI Admin → LDAP
/// Mappings page (rows in <c>LdapGroupMappings</c>); the same code path /// 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 /// future addition. Adding a role here means adding the LDAP mapping row in
/// the deployment; no schema migration is needed. /// the deployment; no schema migration is needed.
/// </para> /// </para>
@@ -69,16 +77,16 @@ public static class AuthorizationPolicies
/// <summary> /// <summary>
/// Read access to the Audit Log #23 surface (Audit Log page, /// Read access to the Audit Log #23 surface (Audit Log page,
/// Configuration Audit Log page, Audit nav group). Granted to the /// Configuration Audit Log page, Audit nav group). Granted to the
/// <c>Audit</c> role, the <c>AuditReadOnly</c> role, and the /// <c>Administrator</c> role and the <c>Viewer</c> role (the latter being
/// <c>Admin</c> role. /// the post-Task-1.7 home of the former <c>AuditReadOnly</c> role).
/// </summary> /// </summary>
public const string OperationalAudit = "OperationalAudit"; public const string OperationalAudit = "OperationalAudit";
/// <summary> /// <summary>
/// Permission to pull a bulk CSV export of the Audit Log. Separate from /// 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 /// 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> /// </summary>
public const string AuditExport = "AuditExport"; 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 /// <c>/api/audit/*</c> routes with a manual Basic-Auth + LDAP role check
/// rather than the ASP.NET authorization-policy pipeline — can reuse the /// rather than the ASP.NET authorization-policy pipeline — can reuse the
/// exact same role set the <see cref="OperationalAudit"/> policy enforces. /// 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> /// </remarks>
public static readonly string[] OperationalAuditRoles = { Roles.Admin, Roles.Audit, Roles.AuditReadOnly }; public static readonly string[] OperationalAuditRoles = { Roles.Administrator, Roles.Viewer };
/// <summary> /// <summary>
/// Roles that satisfy <see cref="AuditExport"/>. A strict subset of /// Roles that satisfy <see cref="AuditExport"/>. A strict subset of
/// <see cref="OperationalAuditRoles"/> — read access does NOT imply /// <see cref="OperationalAuditRoles"/> — read access does NOT imply
/// export permission. /// export permission, so <c>Viewer</c> can read but not export.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Public for the same reason as <see cref="OperationalAuditRoles"/> — /// Public for the same reason as <see cref="OperationalAuditRoles"/> —
/// the ManagementService <c>/api/audit/export</c> route checks roles /// 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> /// </remarks>
public static readonly string[] AuditExportRoles = { Roles.Admin, Roles.Audit }; public static readonly string[] AuditExportRoles = { Roles.Administrator };
/// <summary> /// <summary>
/// Registers the ScadaBridge authorization policies (Admin, Design, Deployment, OperationalAudit, AuditExport). /// Registers the ScadaBridge authorization policies (Admin, Design, Deployment, OperationalAudit, AuditExport).
@@ -115,21 +126,21 @@ public static class AuthorizationPolicies
services.AddAuthorization(options => services.AddAuthorization(options =>
{ {
options.AddPolicy(RequireAdmin, policy => options.AddPolicy(RequireAdmin, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Admin)); policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Administrator));
options.AddPolicy(RequireDesign, policy => options.AddPolicy(RequireDesign, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Design)); policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Designer));
options.AddPolicy(RequireDeployment, policy => 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 // Multi-role permission policies — the policy succeeds when the
// principal holds ANY of the mapped roles. RequireClaim with // principal holds ANY of the mapped roles. RequireClaim with
// multiple allowed values is the right primitive: it checks // multiple allowed values is the right primitive: it checks
// whether *any* role claim's value is in the allowed set, so a // whether *any* role claim's value is in the allowed set, so a
// user with role=Admin (and nothing else) satisfies the // user with role=Administrator (and nothing else) satisfies the
// OperationalAudit policy without needing a separate Audit // OperationalAudit policy, and a user with role=Viewer satisfies
// role claim. // OperationalAudit but not AuditExport.
options.AddPolicy(OperationalAudit, policy => options.AddPolicy(OperationalAudit, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, OperationalAuditRoles)); policy.RequireClaim(JwtTokenService.RoleClaimType, OperationalAuditRoles));
@@ -39,7 +39,7 @@ public class RoleMapper
matchedRoles.Add(mapping.Role); matchedRoles.Add(mapping.Role);
if (mapping.Role.Equals(Roles.Deployment, StringComparison.OrdinalIgnoreCase)) if (mapping.Role.Equals(Roles.Deployer, StringComparison.OrdinalIgnoreCase))
{ {
hasDeploymentRole = true; hasDeploymentRole = true;
+24 -5
View File
@@ -5,18 +5,37 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
/// Security module and downstream authorization checks. /// Security module and downstream authorization checks.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para>
/// Role names appear in three independent contexts: <see cref="RoleMapper"/> /// Role names appear in three independent contexts: <see cref="RoleMapper"/>
/// (LDAP-group → role resolution), <see cref="AuthorizationPolicies"/> /// (LDAP-group → role resolution), <see cref="AuthorizationPolicies"/>
/// (policy <c>RequireClaim</c> values + the audit role arrays), and at LDAP /// (policy <c>RequireClaim</c> values + the audit role arrays), and at LDAP
/// mapping rows configured by an operator. Holding the literals here means a /// mapping rows configured by an operator. Holding the literals here means a
/// rename either succeeds everywhere or fails to compile, eliminating the /// rename either succeeds everywhere or fails to compile, eliminating the
/// "string drift" class that Security-018 documented. /// "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> /// </remarks>
public static class Roles public static class Roles
{ {
public const string Admin = "Admin"; public const string Administrator = "Administrator";
public const string Design = "Design"; public const string Designer = "Designer";
public const string Deployment = "Deployment"; public const string Deployer = "Deployer";
public const string Audit = "Audit"; public const string Viewer = "Viewer";
public const string AuditReadOnly = "AuditReadOnly";
} }
@@ -37,7 +37,7 @@ public class ApiKeyFormAuditDrillinTests : BunitContext
var claims = new[] var claims = new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "admin"), new Claim(JwtTokenService.UsernameClaimType, "admin"),
new Claim(JwtTokenService.RoleClaimType, "Admin"), new Claim(JwtTokenService.RoleClaimType, "Administrator"),
}; };
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
@@ -27,7 +27,7 @@ public class ApiKeysListPageTests : BunitContext
var claims = new[] var claims = new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "admin"), new Claim(JwtTokenService.UsernameClaimType, "admin"),
new Claim(JwtTokenService.RoleClaimType, "Admin"), new Claim(JwtTokenService.RoleClaimType, "Administrator"),
}; };
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
@@ -36,7 +36,7 @@ public class SiteFormAuditDrillinTests : BunitContext
var claims = new[] var claims = new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "admin"), new Claim(JwtTokenService.UsernameClaimType, "admin"),
new Claim(JwtTokenService.RoleClaimType, "Admin"), new Claim(JwtTokenService.RoleClaimType, "Administrator"),
}; };
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
@@ -81,7 +81,7 @@ public class AuditExportEndpointsTests
// Use the real production policy wiring so the endpoint's // Use the real production policy wiring so the endpoint's
// updated AuditExport gate (#23 M7-T15 Bundle G) is what // updated AuditExport gate (#23 M7-T15 Bundle G) is what
// the tests exercise. The fake principal carries the // the tests exercise. The fake principal carries the
// "Admin" role, which AuditExportRoles permits. // "Administrator" role, which AuditExportRoles permits.
services.AddScadaBridgeAuthorization(); services.AddScadaBridgeAuthorization();
services.AddSingleton(repo); services.AddSingleton(repo);
services.AddScoped<IAuditLogExportService, AuditLogExportService>(); services.AddScoped<IAuditLogExportService, AuditLogExportService>();
@@ -288,7 +288,7 @@ public class AuditExportEndpointsTests
var claims = new[] var claims = new[]
{ {
new Claim(ClaimTypes.Name, "test-admin"), new Claim(ClaimTypes.Name, "test-admin"),
new Claim(JwtTokenService.RoleClaimType, "Admin"), new Claim(JwtTokenService.RoleClaimType, "Administrator"),
}; };
var identity = new ClaimsIdentity(claims, SchemeName); var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity); var principal = new ClaimsPrincipal(identity);
@@ -37,7 +37,7 @@ public class SiteScopeServiceTests
[Fact] [Fact]
public async Task DeploymentUserWithNoSiteClaims_IsSystemWide() public async Task DeploymentUserWithNoSiteClaims_IsSystemWide()
{ {
var svc = ForUser(Role("Deployment")); var svc = ForUser(Role("Deployer"));
Assert.True(await svc.IsSystemWideAsync()); Assert.True(await svc.IsSystemWideAsync());
Assert.Empty(await svc.PermittedSiteIdsAsync()); Assert.Empty(await svc.PermittedSiteIdsAsync());
@@ -46,7 +46,7 @@ public class SiteScopeServiceTests
[Fact] [Fact]
public async Task SystemWideUser_FilterSites_ReturnsAllSites() public async Task SystemWideUser_FilterSites_ReturnsAllSites()
{ {
var svc = ForUser(Role("Deployment")); var svc = ForUser(Role("Deployer"));
var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3)); var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3));
@@ -57,7 +57,7 @@ public class SiteScopeServiceTests
public async Task ScopedUser_FilterSites_ReturnsOnlyPermittedSites() public async Task ScopedUser_FilterSites_ReturnsOnlyPermittedSites()
{ {
// Regression: a Deployment user scoped to sites 1 and 3 must NOT see site 2. // Regression: a Deployment user scoped to sites 1 and 3 must NOT see site 2.
var svc = ForUser(Role("Deployment"), SiteClaim(1), SiteClaim(3)); var svc = ForUser(Role("Deployer"), SiteClaim(1), SiteClaim(3));
var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3, 4)); var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3, 4));
@@ -67,7 +67,7 @@ public class SiteScopeServiceTests
[Fact] [Fact]
public async Task ScopedUser_IsSiteAllowed_OnlyForGrantedSites() public async Task ScopedUser_IsSiteAllowed_OnlyForGrantedSites()
{ {
var svc = ForUser(Role("Deployment"), SiteClaim(5)); var svc = ForUser(Role("Deployer"), SiteClaim(5));
Assert.True(await svc.IsSiteAllowedAsync(5)); Assert.True(await svc.IsSiteAllowedAsync(5));
Assert.False(await svc.IsSiteAllowedAsync(6)); Assert.False(await svc.IsSiteAllowedAsync(6));
@@ -76,7 +76,7 @@ public class SiteScopeServiceTests
[Fact] [Fact]
public async Task ScopedUser_IsNotSystemWide_AndReportsItsPermittedIds() public async Task ScopedUser_IsNotSystemWide_AndReportsItsPermittedIds()
{ {
var svc = ForUser(Role("Deployment"), SiteClaim(7), SiteClaim(9)); var svc = ForUser(Role("Deployer"), SiteClaim(7), SiteClaim(9));
Assert.False(await svc.IsSystemWideAsync()); Assert.False(await svc.IsSystemWideAsync());
Assert.Equal(new[] { 7, 9 }, (await svc.PermittedSiteIdsAsync()).OrderBy(x => x)); Assert.Equal(new[] { 7, 9 }, (await svc.PermittedSiteIdsAsync()).OrderBy(x => x));
@@ -85,7 +85,7 @@ public class SiteScopeServiceTests
[Fact] [Fact]
public async Task SystemWideUser_IsSiteAllowed_ForAnySite() public async Task SystemWideUser_IsSiteAllowed_ForAnySite()
{ {
var svc = ForUser(Role("Deployment")); var svc = ForUser(Role("Deployer"));
Assert.True(await svc.IsSiteAllowedAsync(1)); Assert.True(await svc.IsSiteAllowedAsync(1));
Assert.True(await svc.IsSiteAllowedAsync(999)); Assert.True(await svc.IsSiteAllowedAsync(999));
@@ -30,7 +30,7 @@ public class DataConnectionFormTests : BunitContext
var claims = new[] var claims = new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Admin") new Claim(JwtTokenService.RoleClaimType, "Administrator")
}; };
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
@@ -37,7 +37,7 @@ public class DataConnectionsPageTests : BunitContext
var claims = new[] var claims = new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Admin") new Claim(JwtTokenService.RoleClaimType, "Administrator")
}; };
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
@@ -54,7 +54,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext
var claims = new[] var claims = new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "deployer"), new Claim(JwtTokenService.UsernameClaimType, "deployer"),
new Claim(JwtTokenService.RoleClaimType, "Deployment"), new Claim(JwtTokenService.RoleClaimType, "Deployer"),
}; };
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
var authProvider = new TestAuthStateProvider(user); var authProvider = new TestAuthStateProvider(user);
@@ -28,7 +28,7 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext
var claims = new[] var claims = new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Design"), new Claim(JwtTokenService.RoleClaimType, "Designer"),
}; };
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
@@ -76,7 +76,7 @@ public class NavMenuTests : BunitContext
[Fact] [Fact]
public void Sections_AreCollapsedByDefault() public void Sections_AreCollapsedByDefault()
{ {
var cut = RenderWithRoles("Admin", "Design", "Deployment"); var cut = RenderWithRoles("Administrator", "Designer", "Deployer");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
@@ -92,7 +92,7 @@ public class NavMenuTests : BunitContext
[Fact] [Fact]
public void TogglingSection_RevealsItsItems() public void TogglingSection_RevealsItsItems()
{ {
var cut = RenderWithRoles("Deployment"); var cut = RenderWithRoles("Deployer");
Assert.DoesNotContain("/deployment/topology", cut.Markup); Assert.DoesNotContain("/deployment/topology", cut.Markup);
ExpandSection(cut, "Deployment"); ExpandSection(cut, "Deployment");
@@ -105,7 +105,7 @@ public class NavMenuTests : BunitContext
[Fact] [Fact]
public void TogglingSection_PersistsStateToCookie() public void TogglingSection_PersistsStateToCookie()
{ {
var cut = RenderWithRoles("Deployment"); var cut = RenderWithRoles("Deployer");
ExpandSection(cut, "Deployment"); ExpandSection(cut, "Deployment");
@@ -117,7 +117,7 @@ public class NavMenuTests : BunitContext
[Fact] [Fact]
public void NotificationsSection_ShowsAllItems_ForMultiRoleUser() public void NotificationsSection_ShowsAllItems_ForMultiRoleUser()
{ {
var cut = RenderWithRoles("Admin", "Design", "Deployment"); var cut = RenderWithRoles("Administrator", "Designer", "Deployer");
ExpandSection(cut, "Notifications"); ExpandSection(cut, "Notifications");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
@@ -133,7 +133,7 @@ public class NavMenuTests : BunitContext
[Fact] [Fact]
public void NotificationsSection_AdminOnlyUser_SeesOnlySmtp() public void NotificationsSection_AdminOnlyUser_SeesOnlySmtp()
{ {
var cut = RenderWithRoles("Admin"); var cut = RenderWithRoles("Administrator");
ExpandSection(cut, "Notifications"); ExpandSection(cut, "Notifications");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
@@ -148,7 +148,7 @@ public class NavMenuTests : BunitContext
[Fact] [Fact]
public void OldRoutes_AreNoLongerLinked() public void OldRoutes_AreNoLongerLinked()
{ {
var cut = RenderWithRoles("Admin", "Design", "Deployment"); var cut = RenderWithRoles("Administrator", "Designer", "Deployer");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
@@ -38,10 +38,12 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <item><c>AuditExport</c> — additional gate on the Export-CSV button and /// <item><c>AuditExport</c> — additional gate on the Export-CSV button and
/// the streaming export endpoint.</item> /// the streaming export endpoint.</item>
/// </list> /// </list>
/// Both policies are satisfied by the <c>Audit</c> role and (defence in depth) /// Post Task 1.7: <c>AuditExport</c> is satisfied only by the
/// the <c>Admin</c> role — admins see everything by convention in this /// <c>Administrator</c> role (which absorbed the former <c>Audit</c> role);
/// codebase. The tests pin both the page-level + endpoint-level enforcement, /// <c>OperationalAudit</c> is additionally satisfied by the <c>Viewer</c> role
/// and the Export-button visibility split. /// (the home of the former <c>AuditReadOnly</c> role) — Viewer reads but cannot
/// export. The tests pin both the page-level + endpoint-level enforcement, and
/// the Export-button visibility split.
/// </para> /// </para>
/// </summary> /// </summary>
public class AuditLogPagePermissionTests : BunitContext public class AuditLogPagePermissionTests : BunitContext
@@ -106,15 +108,15 @@ public class AuditLogPagePermissionTests : BunitContext
[Fact] [Fact]
public async Task WithoutOperationalAudit_PolicyDenies() public async Task WithoutOperationalAudit_PolicyDenies()
{ {
// A Design-only user (no Audit, no Admin) must NOT satisfy the // A Designer-only user (not Administrator, not Viewer) must NOT satisfy
// OperationalAudit policy. // the OperationalAudit policy.
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddLogging(); services.AddLogging();
services.AddScadaBridgeAuthorization(); services.AddScadaBridgeAuthorization();
using var provider = services.BuildServiceProvider(); using var provider = services.BuildServiceProvider();
var authService = provider.GetRequiredService<IAuthorizationService>(); var authService = provider.GetRequiredService<IAuthorizationService>();
var principal = BuildPrincipal("Design"); var principal = BuildPrincipal("Designer");
var result = await authService.AuthorizeAsync( var result = await authService.AuthorizeAsync(
principal, null, AuthorizationPolicies.OperationalAudit); principal, null, AuthorizationPolicies.OperationalAudit);
@@ -156,11 +158,12 @@ public class AuditLogPagePermissionTests : BunitContext
[Fact] [Fact]
public void WithOperationalAudit_NoAuditExport_PageRenders_ExportButtonHidden() public void WithOperationalAudit_NoAuditExport_PageRenders_ExportButtonHidden()
{ {
// The "Audit" role grants OperationalAudit + AuditExport in the // The "Administrator" role grants OperationalAudit + AuditExport, so we
// default mapping, so we test the split by handing the user ONLY // test the split by handing the user ONLY the "Viewer" role, which maps
// an extra-narrow role that we map ONLY to OperationalAudit: a // to OperationalAudit (read) but NOT AuditExport — the preserved
// fresh "AuditReadOnly" role (see AuthorizationPolicies). // half-SoD after the Task 1.7 AuditReadOnly→Viewer collapse
var cut = RenderAuditLogPage("AuditReadOnly"); // (see AuthorizationPolicies).
var cut = RenderAuditLogPage("Viewer");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
@@ -174,7 +177,7 @@ public class AuditLogPagePermissionTests : BunitContext
[Fact] [Fact]
public void WithOperationalAudit_AndAuditExport_PageRenders_ExportButtonVisible() public void WithOperationalAudit_AndAuditExport_PageRenders_ExportButtonVisible()
{ {
var cut = RenderAuditLogPage("Audit"); var cut = RenderAuditLogPage("Administrator");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
@@ -186,9 +189,9 @@ public class AuditLogPagePermissionTests : BunitContext
[Fact] [Fact]
public void AdminUser_SeesPage_AndExportButton() public void AdminUser_SeesPage_AndExportButton()
{ {
// Admin holds every permission by convention — both policies must // Administrator holds every permission by convention — both policies must
// succeed for a plain Admin user. // succeed for a plain Administrator user.
var cut = RenderAuditLogPage("Admin"); var cut = RenderAuditLogPage("Administrator");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
@@ -204,9 +207,9 @@ public class AuditLogPagePermissionTests : BunitContext
[Fact] [Fact]
public async Task AuditExportEndpoint_WithoutAuditExport_Returns403() public async Task AuditExportEndpoint_WithoutAuditExport_Returns403()
{ {
// A user holding only Design must NOT be able to call the export // A user holding only Designer must NOT be able to call the export
// endpoint. Live wiring re-uses AuthorizationPolicies.AuditExport. // endpoint. Live wiring re-uses AuthorizationPolicies.AuditExport.
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Design" }); var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Designer" });
using (host) using (host)
{ {
var response = await client.GetAsync("/api/centralui/audit/export"); var response = await client.GetAsync("/api/centralui/audit/export");
@@ -217,7 +220,7 @@ public class AuditLogPagePermissionTests : BunitContext
[Fact] [Fact]
public async Task AuditExportEndpoint_WithAuditExport_Returns200() public async Task AuditExportEndpoint_WithAuditExport_Returns200()
{ {
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Audit" }); var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Administrator" });
using (host) using (host)
{ {
var response = await client.GetAsync("/api/centralui/audit/export"); var response = await client.GetAsync("/api/centralui/audit/export");
@@ -228,8 +231,9 @@ public class AuditLogPagePermissionTests : BunitContext
[Fact] [Fact]
public async Task AuditExportEndpoint_AdminAlone_Returns200() public async Task AuditExportEndpoint_AdminAlone_Returns200()
{ {
// Admin alone (no Audit role) must still pass — defence in depth. // Administrator alone must pass — it absorbed the former Audit role and
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Admin" }); // holds AuditExport by convention (defence in depth).
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Administrator" });
using (host) using (host)
{ {
var response = await client.GetAsync("/api/centralui/audit/export"); var response = await client.GetAsync("/api/centralui/audit/export");
@@ -238,12 +242,12 @@ public class AuditLogPagePermissionTests : BunitContext
} }
[Fact] [Fact]
public async Task AuditExportEndpoint_AuditReadOnly_Returns403() public async Task AuditExportEndpoint_Viewer_Returns403()
{ {
// AuditReadOnly grants OperationalAudit but NOT AuditExport, so the // Viewer (former AuditReadOnly) grants OperationalAudit but NOT
// endpoint must refuse — the page is readable but the bulk export // AuditExport, so the endpoint must refuse — the page is readable but
// path is gated separately. // the bulk export path is gated separately (preserved half-SoD).
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "AuditReadOnly" }); var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Viewer" });
using (host) using (host)
{ {
var response = await client.GetAsync("/api/centralui/audit/export"); var response = await client.GetAsync("/api/centralui/audit/export");
@@ -118,7 +118,7 @@ public class AuditLogPageScaffoldTests : BunitContext
[Fact] [Fact]
public void AuditLogPage_Renders_PageHeading() public void AuditLogPage_Renders_PageHeading()
{ {
var cut = RenderAuditLogPage("Admin"); var cut = RenderAuditLogPage("Administrator");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
@@ -132,7 +132,7 @@ public class AuditLogPageScaffoldTests : BunitContext
[Fact] [Fact]
public void NavMenu_Contains_AuditGroup_With_AuditLog_Link() public void NavMenu_Contains_AuditGroup_With_AuditLog_Link()
{ {
var cut = RenderNavMenu("Admin", "Design", "Deployment"); var cut = RenderNavMenu("Administrator", "Designer", "Deployer");
ExpandNavSection(cut, "Audit"); ExpandNavSection(cut, "Audit");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
@@ -145,7 +145,7 @@ public class AuditLogPageScaffoldTests : BunitContext
[Fact] [Fact]
public void NavMenu_Contains_ConfigurationAuditLog_Link_UnderAuditGroup() public void NavMenu_Contains_ConfigurationAuditLog_Link_UnderAuditGroup()
{ {
var cut = RenderNavMenu("Admin", "Design", "Deployment"); var cut = RenderNavMenu("Administrator", "Designer", "Deployer");
ExpandNavSection(cut, "Audit"); ExpandNavSection(cut, "Audit");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
@@ -178,7 +178,7 @@ public class AuditLogPageScaffoldTests : BunitContext
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>()) _queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>())); .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
var cut = RenderAuditLogPageWithQuery($"correlationId={corr}", "Admin"); var cut = RenderAuditLogPageWithQuery($"correlationId={corr}", "Administrator");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
@@ -201,7 +201,7 @@ public class AuditLogPageScaffoldTests : BunitContext
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>()) _queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>())); .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Admin"); var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Administrator");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
@@ -217,7 +217,7 @@ public class AuditLogPageScaffoldTests : BunitContext
{ {
_queryService = Substitute.For<IAuditLogQueryService>(); _queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderAuditLogPageWithQuery("executionId=not-a-guid", "Admin"); var cut = RenderAuditLogPageWithQuery("executionId=not-a-guid", "Administrator");
// An unparseable executionId leaves ExecutionId null. With no other filter // An unparseable executionId leaves ExecutionId null. With no other filter
// params present the page renders but does NOT call the query service. // params present the page renders but does NOT call the query service.
@@ -239,7 +239,7 @@ public class AuditLogPageScaffoldTests : BunitContext
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>()) _queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>())); .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
var cut = RenderAuditLogPageWithQuery($"parentExecutionId={parentExecutionId}", "Admin"); var cut = RenderAuditLogPageWithQuery($"parentExecutionId={parentExecutionId}", "Administrator");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
@@ -255,7 +255,7 @@ public class AuditLogPageScaffoldTests : BunitContext
{ {
_queryService = Substitute.For<IAuditLogQueryService>(); _queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderAuditLogPageWithQuery("parentExecutionId=not-a-guid", "Admin"); var cut = RenderAuditLogPageWithQuery("parentExecutionId=not-a-guid", "Administrator");
// An unparseable parentExecutionId leaves ParentExecutionId null. With no // An unparseable parentExecutionId leaves ParentExecutionId null. With no
// other filter params present the page renders but does NOT call the query // other filter params present the page renders but does NOT call the query
@@ -274,7 +274,7 @@ public class AuditLogPageScaffoldTests : BunitContext
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>()) _queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>())); .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
var cut = RenderAuditLogPageWithQuery("target=ExternalSystem-Alpha", "Admin"); var cut = RenderAuditLogPageWithQuery("target=ExternalSystem-Alpha", "Administrator");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
@@ -292,7 +292,7 @@ public class AuditLogPageScaffoldTests : BunitContext
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>()) _queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>())); .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
var cut = RenderAuditLogPageWithQuery("site=plant-a", "Admin"); var cut = RenderAuditLogPageWithQuery("site=plant-a", "Administrator");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
@@ -314,7 +314,7 @@ public class AuditLogPageScaffoldTests : BunitContext
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>()) _queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>())); .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
var cut = RenderAuditLogPageWithQuery("status=Failed", "Admin"); var cut = RenderAuditLogPageWithQuery("status=Failed", "Administrator");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
@@ -331,7 +331,7 @@ public class AuditLogPageScaffoldTests : BunitContext
{ {
_queryService = Substitute.For<IAuditLogQueryService>(); _queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderAuditLogPageWithQuery("status=NotARealStatus", "Admin"); var cut = RenderAuditLogPageWithQuery("status=NotARealStatus", "Administrator");
// An unparseable status value leaves Status null. With no other filter // An unparseable status value leaves Status null. With no other filter
// params present the page renders but does NOT call the query service // params present the page renders but does NOT call the query service
@@ -348,7 +348,7 @@ public class AuditLogPageScaffoldTests : BunitContext
{ {
_queryService = Substitute.For<IAuditLogQueryService>(); _queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderAuditLogPage("Admin"); var cut = RenderAuditLogPage("Administrator");
// The grid is in "no filter" state — the page heading renders, but the // The grid is in "no filter" state — the page heading renders, but the
// query service must NOT be hit because nothing told us to load. // query service must NOT be hit because nothing told us to load.
@@ -88,7 +88,7 @@ public class TransportExportPageTests : BunitContext
SourceEnvironment = "test-cluster", SourceEnvironment = "test-cluster",
})); }));
var principal = BuildPrincipal("alice", "Design"); var principal = BuildPrincipal("alice", "Designer");
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
Services.AddAuthorizationCore(); Services.AddAuthorizationCore();
} }
@@ -304,8 +304,8 @@ public class TransportExportPageTests : BunitContext
using var provider = services.BuildServiceProvider(); using var provider = services.BuildServiceProvider();
var authService = provider.GetRequiredService<IAuthorizationService>(); var authService = provider.GetRequiredService<IAuthorizationService>();
// Audit-only user — has a role but it isn't Design. // Administrator user — has a role but it isn't Designer.
var principal = BuildPrincipal("bob", "Audit"); var principal = BuildPrincipal("bob", "Administrator");
var result = await authService.AuthorizeAsync( var result = await authService.AuthorizeAsync(
principal, null, AuthorizationPolicies.RequireDesign); principal, null, AuthorizationPolicies.RequireDesign);
@@ -62,7 +62,7 @@ public class TransportImportPageTests : BunitContext
dbContext.Database.EnsureCreated(); dbContext.Database.EnsureCreated();
Services.AddSingleton(dbContext); Services.AddSingleton(dbContext);
var principal = BuildPrincipal("alice", "Admin"); var principal = BuildPrincipal("alice", "Administrator");
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
Services.AddAuthorizationCore(); Services.AddAuthorizationCore();
} }
@@ -299,7 +299,7 @@ public class TransportImportPageTests : BunitContext
var authService = provider.GetRequiredService<IAuthorizationService>(); var authService = provider.GetRequiredService<IAuthorizationService>();
// Design-only user — has a role but it isn't Admin. // Design-only user — has a role but it isn't Admin.
var principal = BuildPrincipal("bob", "Design"); var principal = BuildPrincipal("bob", "Designer");
var result = await authService.AuthorizeAsync( var result = await authService.AuthorizeAsync(
principal, null, AuthorizationPolicies.RequireAdmin); principal, null, AuthorizationPolicies.RequireAdmin);
@@ -81,7 +81,7 @@ public class ExecutionTreePageTests : BunitContext
Node(child, root), Node(child, root),
})); }));
var cut = RenderPage($"executionId={child}", "Admin"); var cut = RenderPage($"executionId={child}", "Administrator");
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
@@ -96,7 +96,7 @@ public class ExecutionTreePageTests : BunitContext
{ {
_queryService = Substitute.For<IAuditLogQueryService>(); _queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderPage(query: null, "Admin"); var cut = RenderPage(query: null, "Administrator");
cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup)); cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup));
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>()); _queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
@@ -107,7 +107,7 @@ public class ExecutionTreePageTests : BunitContext
{ {
_queryService = Substitute.For<IAuditLogQueryService>(); _queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderPage("executionId=not-a-guid", "Admin"); var cut = RenderPage("executionId=not-a-guid", "Administrator");
cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup)); cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup));
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>()); _queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
@@ -131,7 +131,7 @@ public class ExecutionTreePageTests : BunitContext
// AuditEventDetail (reachable from the modal) owns a clipboard interop call. // AuditEventDetail (reachable from the modal) owns a clipboard interop call.
JSInterop.Mode = JSRuntimeMode.Loose; JSInterop.Mode = JSRuntimeMode.Loose;
var cut = RenderPage($"executionId={child}", "Admin"); var cut = RenderPage($"executionId={child}", "Administrator");
// The modal is absent until a node is activated. // The modal is absent until a node is activated.
Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]")); Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]"));
@@ -163,7 +163,7 @@ public class ExecutionTreePageTests : BunitContext
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>())); .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
JSInterop.Mode = JSRuntimeMode.Loose; JSInterop.Mode = JSRuntimeMode.Loose;
var cut = RenderPage($"executionId={child}", "Admin"); var cut = RenderPage($"executionId={child}", "Administrator");
cut.Find($"[data-test=\"tree-node-{child}\"] .execution-tree-body").DoubleClick(); cut.Find($"[data-test=\"tree-node-{child}\"] .execution-tree-body").DoubleClick();
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
@@ -82,7 +82,7 @@ public class HealthPageTests : BunitContext
var claims = new[] var claims = new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Admin"), new Claim(JwtTokenService.RoleClaimType, "Administrator"),
}; };
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
@@ -69,7 +69,7 @@ public class NotificationKpisPageTests : BunitContext
var claims = new[] var claims = new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Deployment"), new Claim(JwtTokenService.RoleClaimType, "Deployer"),
}; };
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
@@ -23,7 +23,7 @@ public class NotificationListsPageTests : BunitContext
var claims = new[] var claims = new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Design"), new Claim(JwtTokenService.RoleClaimType, "Designer"),
}; };
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
@@ -89,7 +89,7 @@ public class NotificationReportDetailModalTests : BunitContext
var claims = new[] var claims = new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Deployment"), new Claim(JwtTokenService.RoleClaimType, "Deployer"),
}; };
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
@@ -74,7 +74,7 @@ public class NotificationReportPageTests : BunitContext
var claims = new[] var claims = new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Deployment"), new Claim(JwtTokenService.RoleClaimType, "Deployer"),
}; };
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
@@ -173,7 +173,7 @@ public sealed class QueryStringDrillInTests
var claims = new[] var claims = new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Deployment"), new Claim(JwtTokenService.RoleClaimType, "Deployer"),
}; };
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
@@ -241,7 +241,7 @@ public sealed class QueryStringDrillInTests
var claims = new List<Claim> var claims = new List<Claim>
{ {
new(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.UsernameClaimType, "alice"), new(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.UsernameClaimType, "alice"),
new(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.RoleClaimType, "Admin"), new(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.RoleClaimType, "Administrator"),
}; };
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
@@ -89,7 +89,7 @@ public class SiteCallsReportPageTests : BunitContext
var claims = new[] var claims = new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Deployment"), new Claim(JwtTokenService.RoleClaimType, "Deployer"),
}; };
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
@@ -494,7 +494,7 @@ public class SiteCallsReportPageTests : BunitContext
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[] var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "scoped"), new Claim(JwtTokenService.UsernameClaimType, "scoped"),
new Claim(JwtTokenService.RoleClaimType, "Deployment"), new Claim(JwtTokenService.RoleClaimType, "Deployer"),
new Claim(JwtTokenService.SiteIdClaimType, "1"), // Plant A only new Claim(JwtTokenService.SiteIdClaimType, "1"), // Plant A only
}, "TestAuth")); }, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));
@@ -21,7 +21,7 @@ public class SmtpConfigurationPageTests : BunitContext
var claims = new[] var claims = new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Admin"), new Claim(JwtTokenService.RoleClaimType, "Administrator"),
}; };
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
@@ -52,7 +52,7 @@ public class TemplatesPageTests : BunitContext
var claims = new[] var claims = new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Design") new Claim(JwtTokenService.RoleClaimType, "Designer")
}; };
var identity = new ClaimsIdentity(claims, "TestAuth"); var identity = new ClaimsIdentity(claims, "TestAuth");
var user = new ClaimsPrincipal(identity); var user = new ClaimsPrincipal(identity);
@@ -88,7 +88,7 @@ public class TopologyPageTests : BunitContext
var claims = new[] var claims = new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Deployment") new Claim(JwtTokenService.RoleClaimType, "Deployer")
}; };
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
@@ -217,7 +217,7 @@ public class TopologyPageTests : BunitContext
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[] var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
{ {
new Claim(JwtTokenService.UsernameClaimType, "scoped-tester"), new Claim(JwtTokenService.UsernameClaimType, "scoped-tester"),
new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.RoleClaimType, "Deployment"), new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.RoleClaimType, "Deployer"),
// Permitted on site 1 only. // Permitted on site 1 only.
new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.SiteIdClaimType, "1"), new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.SiteIdClaimType, "1"),
}, "TestAuth")); }, "TestAuth"));
@@ -38,21 +38,21 @@ public class SecurityRepositoryTests : IDisposable
[Fact] [Fact]
public async Task AddMapping_AndGetById_ReturnsMapping() public async Task AddMapping_AndGetById_ReturnsMapping()
{ {
var mapping = new LdapGroupMapping("CN=Admins,DC=test", "Admin"); var mapping = new LdapGroupMapping("CN=Admins,DC=test", "Administrator");
await _repository.AddMappingAsync(mapping); await _repository.AddMappingAsync(mapping);
await _repository.SaveChangesAsync(); await _repository.SaveChangesAsync();
var loaded = await _repository.GetMappingByIdAsync(mapping.Id); var loaded = await _repository.GetMappingByIdAsync(mapping.Id);
Assert.NotNull(loaded); Assert.NotNull(loaded);
Assert.Equal("CN=Admins,DC=test", loaded.LdapGroupName); Assert.Equal("CN=Admins,DC=test", loaded.LdapGroupName);
Assert.Equal("Admin", loaded.Role); Assert.Equal("Administrator", loaded.Role);
} }
[Fact] [Fact]
public async Task GetAllMappings_ReturnsAll() public async Task GetAllMappings_ReturnsAll()
{ {
await _repository.AddMappingAsync(new LdapGroupMapping("Group1", "Admin")); await _repository.AddMappingAsync(new LdapGroupMapping("Group1", "Administrator"));
await _repository.AddMappingAsync(new LdapGroupMapping("Group2", "Design")); await _repository.AddMappingAsync(new LdapGroupMapping("Group2", "Designer"));
await _repository.SaveChangesAsync(); await _repository.SaveChangesAsync();
// +1 for seed data // +1 for seed data
@@ -63,12 +63,12 @@ public class SecurityRepositoryTests : IDisposable
[Fact] [Fact]
public async Task GetMappingsByRole_FiltersCorrectly() public async Task GetMappingsByRole_FiltersCorrectly()
{ {
await _repository.AddMappingAsync(new LdapGroupMapping("Designers", "Design")); await _repository.AddMappingAsync(new LdapGroupMapping("Designers", "Designer"));
await _repository.AddMappingAsync(new LdapGroupMapping("Deployers", "Deployment")); await _repository.AddMappingAsync(new LdapGroupMapping("Deployers", "Deployer"));
await _repository.SaveChangesAsync(); await _repository.SaveChangesAsync();
var designMappings = await _repository.GetMappingsByRoleAsync("Design"); var designMappings = await _repository.GetMappingsByRoleAsync("Designer");
// Seed data includes "SCADA-Designers" with role "Design", plus the one we added // Seed data includes "SCADA-Designers" with role "Designer", plus the one we added
Assert.Equal(2, designMappings.Count); Assert.Equal(2, designMappings.Count);
Assert.Contains(designMappings, m => m.LdapGroupName == "Designers"); Assert.Contains(designMappings, m => m.LdapGroupName == "Designers");
} }
@@ -76,23 +76,23 @@ public class SecurityRepositoryTests : IDisposable
[Fact] [Fact]
public async Task UpdateMapping_PersistsChange() public async Task UpdateMapping_PersistsChange()
{ {
var mapping = new LdapGroupMapping("OldGroup", "Admin"); var mapping = new LdapGroupMapping("OldGroup", "Administrator");
await _repository.AddMappingAsync(mapping); await _repository.AddMappingAsync(mapping);
await _repository.SaveChangesAsync(); await _repository.SaveChangesAsync();
mapping.Role = "Design"; mapping.Role = "Designer";
await _repository.UpdateMappingAsync(mapping); await _repository.UpdateMappingAsync(mapping);
await _repository.SaveChangesAsync(); await _repository.SaveChangesAsync();
_context.ChangeTracker.Clear(); _context.ChangeTracker.Clear();
var loaded = await _repository.GetMappingByIdAsync(mapping.Id); var loaded = await _repository.GetMappingByIdAsync(mapping.Id);
Assert.Equal("Design", loaded!.Role); Assert.Equal("Designer", loaded!.Role);
} }
[Fact] [Fact]
public async Task DeleteMapping_RemovesEntity() public async Task DeleteMapping_RemovesEntity()
{ {
var mapping = new LdapGroupMapping("ToDelete", "Admin"); var mapping = new LdapGroupMapping("ToDelete", "Administrator");
await _repository.AddMappingAsync(mapping); await _repository.AddMappingAsync(mapping);
await _repository.SaveChangesAsync(); await _repository.SaveChangesAsync();
@@ -108,7 +108,7 @@ public class SecurityRepositoryTests : IDisposable
{ {
var site = new Site("Site1", "SITE-001"); var site = new Site("Site1", "SITE-001");
_context.Sites.Add(site); _context.Sites.Add(site);
var mapping = new LdapGroupMapping("Deployers", "Deployment"); var mapping = new LdapGroupMapping("Deployers", "Deployer");
await _repository.AddMappingAsync(mapping); await _repository.AddMappingAsync(mapping);
await _repository.SaveChangesAsync(); await _repository.SaveChangesAsync();
@@ -126,7 +126,7 @@ public class SecurityRepositoryTests : IDisposable
{ {
var site = new Site("Site1", "SITE-001"); var site = new Site("Site1", "SITE-001");
_context.Sites.Add(site); _context.Sites.Add(site);
var mapping = new LdapGroupMapping("Group", "Deployment"); var mapping = new LdapGroupMapping("Group", "Deployer");
await _repository.AddMappingAsync(mapping); await _repository.AddMappingAsync(mapping);
await _repository.SaveChangesAsync(); await _repository.SaveChangesAsync();
@@ -145,7 +145,7 @@ public class SecurityRepositoryTests : IDisposable
var site1 = new Site("Site1", "SITE-001"); var site1 = new Site("Site1", "SITE-001");
var site2 = new Site("Site2", "SITE-002"); var site2 = new Site("Site2", "SITE-002");
_context.Sites.AddRange(site1, site2); _context.Sites.AddRange(site1, site2);
var mapping = new LdapGroupMapping("Group", "Deployment"); var mapping = new LdapGroupMapping("Group", "Deployer");
await _repository.AddMappingAsync(mapping); await _repository.AddMappingAsync(mapping);
await _repository.SaveChangesAsync(); await _repository.SaveChangesAsync();
@@ -167,7 +167,7 @@ public class SecurityRepositoryTests : IDisposable
{ {
var site = new Site("Site1", "SITE-001"); var site = new Site("Site1", "SITE-001");
_context.Sites.Add(site); _context.Sites.Add(site);
var mapping = new LdapGroupMapping("Group", "Deployment"); var mapping = new LdapGroupMapping("Group", "Deployer");
await _repository.AddMappingAsync(mapping); await _repository.AddMappingAsync(mapping);
await _repository.SaveChangesAsync(); await _repository.SaveChangesAsync();
@@ -31,7 +31,8 @@ public class SeedDataTests : IDisposable
.SingleOrDefaultAsync(m => m.LdapGroupName == "SCADA-Admins"); .SingleOrDefaultAsync(m => m.LdapGroupName == "SCADA-Admins");
Assert.NotNull(adminMapping); Assert.NotNull(adminMapping);
Assert.Equal("Admin", adminMapping.Role); // Role VALUE canonicalized to "Administrator" (Task 1.7); group NAME unchanged.
Assert.Equal("Administrator", adminMapping.Role);
Assert.Equal(1, adminMapping.Id); Assert.Equal(1, adminMapping.Id);
} }
} }
@@ -253,7 +253,7 @@ public class DbContextTests : IDisposable
_context.Sites.Add(site); _context.Sites.Add(site);
_context.SaveChanges(); _context.SaveChanges();
var mapping = new LdapGroupMapping("CN=Admins,DC=example,DC=com", "Admin"); var mapping = new LdapGroupMapping("CN=Admins,DC=example,DC=com", "Administrator");
_context.LdapGroupMappings.Add(mapping); _context.LdapGroupMappings.Add(mapping);
_context.SaveChanges(); _context.SaveChanges();
@@ -28,7 +28,7 @@ public class AuditTransactionTests : IClassFixture<ScadaBridgeWebApplicationFact
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>(); var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// Add a mapping and an audit log entry in the same unit of work // Add a mapping and an audit log entry in the same unit of work
var mapping = new LdapGroupMapping("test-group-audit", "Admin"); var mapping = new LdapGroupMapping("test-group-audit", "Administrator");
await securityRepo.AddMappingAsync(mapping); await securityRepo.AddMappingAsync(mapping);
await auditService.LogAsync( await auditService.LogAsync(
@@ -37,7 +37,7 @@ public class AuditTransactionTests : IClassFixture<ScadaBridgeWebApplicationFact
entityType: "LdapGroupMapping", entityType: "LdapGroupMapping",
entityId: "0", // ID not yet assigned entityId: "0", // ID not yet assigned
entityName: "test-group-audit", entityName: "test-group-audit",
afterState: new { Group = "test-group-audit", Role = "Admin" }); afterState: new { Group = "test-group-audit", Role = "Administrator" });
// Both should be in the change tracker before saving // Both should be in the change tracker before saving
var trackedEntities = dbContext.ChangeTracker.Entries().Count(e => e.State == EntityState.Added); var trackedEntities = dbContext.ChangeTracker.Entries().Count(e => e.State == EntityState.Added);
@@ -63,7 +63,7 @@ public class AuditTransactionTests : IClassFixture<ScadaBridgeWebApplicationFact
var auditService = scope1.ServiceProvider.GetRequiredService<IAuditService>(); var auditService = scope1.ServiceProvider.GetRequiredService<IAuditService>();
// Add entity + audit but do NOT call SaveChangesAsync // Add entity + audit but do NOT call SaveChangesAsync
var mapping = new LdapGroupMapping("orphan-group", "Design"); var mapping = new LdapGroupMapping("orphan-group", "Designer");
await securityRepo.AddMappingAsync(mapping); await securityRepo.AddMappingAsync(mapping);
await auditService.LogAsync("test", "Create", "LdapGroupMapping", "0", "orphan-group", null); await auditService.LogAsync("test", "Create", "LdapGroupMapping", "0", "orphan-group", null);
@@ -64,7 +64,7 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
var token = jwtService.GenerateToken( var token = jwtService.GenerateToken(
displayName: "Test User", displayName: "Test User",
username: "testuser", username: "testuser",
roles: new[] { "Admin", "Design" }, roles: new[] { "Administrator", "Designer" },
permittedSiteIds: null); permittedSiteIds: null);
Assert.NotNull(token); Assert.NotNull(token);
@@ -78,8 +78,8 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
Assert.Equal("Test User", displayName); Assert.Equal("Test User", displayName);
Assert.Equal("testuser", username); Assert.Equal("testuser", username);
Assert.Contains("Admin", roles); Assert.Contains("Administrator", roles);
Assert.Contains("Design", roles); Assert.Contains("Designer", roles);
} }
[Fact] [Fact]
@@ -91,7 +91,7 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
var token = jwtService.GenerateToken( var token = jwtService.GenerateToken(
displayName: "Deployer", displayName: "Deployer",
username: "deployer1", username: "deployer1",
roles: new[] { "Deployment" }, roles: new[] { "Deployer" },
permittedSiteIds: new[] { "1", "3" }); permittedSiteIds: new[] { "1", "3" });
var principal = jwtService.ValidateToken(token); var principal = jwtService.ValidateToken(token);
@@ -33,7 +33,7 @@ public class CentralFailoverTests
var token = jwtServiceA.GenerateToken( var token = jwtServiceA.GenerateToken(
displayName: "Failover User", displayName: "Failover User",
username: "failover_test", username: "failover_test",
roles: new[] { "Admin" }, roles: new[] { "Administrator" },
permittedSiteIds: null); permittedSiteIds: null);
// Validate with a second instance (same signing key = simulated failover) // Validate with a second instance (same signing key = simulated failover)
@@ -54,7 +54,7 @@ public class CentralFailoverTests
var token = jwtService.GenerateToken( var token = jwtService.GenerateToken(
displayName: "Scoped User", displayName: "Scoped User",
username: "scoped_user", username: "scoped_user",
roles: new[] { "Deployment" }, roles: new[] { "Deployer" },
permittedSiteIds: new[] { "site-1", "site-2", "site-5" }); permittedSiteIds: new[] { "site-1", "site-2", "site-5" });
var principal = jwtService.ValidateToken(token); var principal = jwtService.ValidateToken(token);
@@ -77,7 +77,7 @@ public class CentralFailoverTests
var token = jwtServiceA.GenerateToken( var token = jwtServiceA.GenerateToken(
displayName: "User", displayName: "User",
username: "user", username: "user",
roles: new[] { "Admin" }, roles: new[] { "Administrator" },
permittedSiteIds: null); permittedSiteIds: null);
// Token from A should NOT validate on B (different key) // Token from A should NOT validate on B (different key)
@@ -109,7 +109,7 @@ public class CentralFailoverTests
var token = jwtService.GenerateToken( var token = jwtService.GenerateToken(
displayName: "Expired User", displayName: "Expired User",
username: "expired_user", username: "expired_user",
roles: new[] { "Admin" }, roles: new[] { "Administrator" },
permittedSiteIds: null); permittedSiteIds: null);
var principal = jwtService.ValidateToken(token); var principal = jwtService.ValidateToken(token);
@@ -145,7 +145,7 @@ public class CentralFailoverTests
var token = jwtService.GenerateToken( var token = jwtService.GenerateToken(
displayName: "User", displayName: "User",
username: "user", username: "user",
roles: new[] { "Admin" }, roles: new[] { "Administrator" },
permittedSiteIds: null); permittedSiteIds: null);
var principal = jwtService.ValidateToken(token); var principal = jwtService.ValidateToken(token);
@@ -48,7 +48,7 @@ public class SecurityHardeningTests
var token = jwtService.GenerateToken( var token = jwtService.GenerateToken(
displayName: "Test", displayName: "Test",
username: "test", username: "test",
roles: new[] { "Admin" }, roles: new[] { "Administrator" },
permittedSiteIds: null); permittedSiteIds: null);
Assert.NotNull(token); Assert.NotNull(token);
@@ -96,7 +96,7 @@ public class SecurityHardeningTests
var token = jwtService.GenerateToken( var token = jwtService.GenerateToken(
displayName: "Test", displayName: "Test",
username: "test", username: "test",
roles: new[] { "Admin" }, roles: new[] { "Administrator" },
permittedSiteIds: null); permittedSiteIds: null);
// JWT tokens are base64-encoded; the signing key should not appear in the payload // JWT tokens are base64-encoded; the signing key should not appear in the payload
@@ -121,7 +121,7 @@ public class SecurityHardeningTests
var token = jwtService.GenerateToken( var token = jwtService.GenerateToken(
displayName: "User", displayName: "User",
username: "user", username: "user",
roles: new[] { "Admin" }, roles: new[] { "Administrator" },
permittedSiteIds: null); permittedSiteIds: null);
// Tamper with the token payload (second segment) // Tamper with the token payload (second segment)
@@ -150,7 +150,7 @@ public class SecurityHardeningTests
var originalToken = jwtService.GenerateToken( var originalToken = jwtService.GenerateToken(
displayName: "Original User", displayName: "Original User",
username: "orig_user", username: "orig_user",
roles: new[] { "Admin", "Design" }, roles: new[] { "Administrator", "Designer" },
permittedSiteIds: new[] { "site-1" }); permittedSiteIds: new[] { "site-1" });
var principal = jwtService.ValidateToken(originalToken); var principal = jwtService.ValidateToken(originalToken);
@@ -159,7 +159,7 @@ public class SecurityHardeningTests
// Refresh the token // Refresh the token
var refreshedToken = jwtService.RefreshToken( var refreshedToken = jwtService.RefreshToken(
principal!, principal!,
new[] { "Admin", "Design" }, new[] { "Administrator", "Designer" },
new[] { "site-1" }); new[] { "site-1" });
Assert.NotNull(refreshedToken); Assert.NotNull(refreshedToken);
@@ -18,7 +18,7 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// the seam (the real <c>LibraryInboundApiKeyAdmin</c> + SQLite mapping is covered end-to-end /// the seam (the real <c>LibraryInboundApiKeyAdmin</c> + SQLite mapping is covered end-to-end
/// by the Security project's <c>LibraryInboundApiKeyAdminTests</c>). They verify the actor's /// by the Security project's <c>LibraryInboundApiKeyAdminTests</c>). They verify the actor's
/// dispatch, response shapes (string keyId, one-time token, methods), the preserved ScadaBridge /// dispatch, response shapes (string keyId, one-time token, methods), the preserved ScadaBridge
/// management-audit calls, and that the "Admin" role gate still applies to all five commands. /// management-audit calls, and that the "Administrator" role gate still applies to all five commands.
/// </summary> /// </summary>
public class ApiKeyCreationTests : TestKit, IDisposable public class ApiKeyCreationTests : TestKit, IDisposable
{ {
@@ -48,7 +48,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
public void CreateApiKey_ReturnsKeyIdAndOneTimeToken() public void CreateApiKey_ReturnsKeyIdAndOneTimeToken()
{ {
var actor = CreateActor(); var actor = CreateActor();
actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA", "MethodB" }), "Admin")); actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA", "MethodB" }), "Administrator"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
@@ -73,7 +73,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
public void CreateApiKey_AuditsTheCreate() public void CreateApiKey_AuditsTheCreate()
{ {
var actor = CreateActor(); var actor = CreateActor();
actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA" }), "Admin")); actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA" }), "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5)); ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_auditService.Received(1).LogAsync( _auditService.Received(1).LogAsync(
@@ -84,7 +84,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
public void CreateApiKey_ResponseDoesNotEchoAHash() public void CreateApiKey_ResponseDoesNotEchoAHash()
{ {
var actor = CreateActor(); var actor = CreateActor();
actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA" }), "Admin")); actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA" }), "Administrator"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
@@ -100,7 +100,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
_admin.Seed("key-2", "Service B", enabled: false, "M3"); _admin.Seed("key-2", "Service B", enabled: false, "M3");
var actor = CreateActor(); var actor = CreateActor();
actor.Tell(Envelope(new ListApiKeysCommand(), "Admin")); actor.Tell(Envelope(new ListApiKeysCommand(), "Administrator"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
@@ -125,7 +125,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
_admin.Seed("key-1", "Service A", enabled: true, "M1"); _admin.Seed("key-1", "Service A", enabled: true, "M1");
var actor = CreateActor(); var actor = CreateActor();
actor.Tell(Envelope(new UpdateApiKeyCommand("key-1", false), "Admin")); actor.Tell(Envelope(new UpdateApiKeyCommand("key-1", false), "Administrator"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
@@ -145,7 +145,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
_admin.Seed("key-1", "Service A", enabled: true, "M1"); _admin.Seed("key-1", "Service A", enabled: true, "M1");
var actor = CreateActor(); var actor = CreateActor();
actor.Tell(Envelope(new DeleteApiKeyCommand("key-1"), "Admin")); actor.Tell(Envelope(new DeleteApiKeyCommand("key-1"), "Administrator"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
@@ -162,7 +162,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
_admin.Seed("key-1", "Service A", enabled: true, "Old1", "Old2"); _admin.Seed("key-1", "Service A", enabled: true, "Old1", "Old2");
var actor = CreateActor(); var actor = CreateActor();
actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-1", new[] { "New1", "New2", "New3" }), "Admin")); actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-1", new[] { "New1", "New2", "New3" }), "Administrator"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
@@ -188,7 +188,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
{ {
// No keys seeded — "key-unknown" does not exist. // No keys seeded — "key-unknown" does not exist.
var actor = CreateActor(); var actor = CreateActor();
actor.Tell(Envelope(new UpdateApiKeyCommand("key-unknown", false), "Admin")); actor.Tell(Envelope(new UpdateApiKeyCommand("key-unknown", false), "Administrator"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
@@ -202,7 +202,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
public void SetApiKeyMethods_UnknownKey_ReturnsManagementError_AndDoesNotAudit() public void SetApiKeyMethods_UnknownKey_ReturnsManagementError_AndDoesNotAudit()
{ {
var actor = CreateActor(); var actor = CreateActor();
actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-unknown", new[] { "M1" }), "Admin")); actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-unknown", new[] { "M1" }), "Administrator"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
@@ -215,7 +215,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
public void DeleteApiKey_UnknownKey_ReturnsManagementError_AndDoesNotAudit() public void DeleteApiKey_UnknownKey_ReturnsManagementError_AndDoesNotAudit()
{ {
var actor = CreateActor(); var actor = CreateActor();
actor.Tell(Envelope(new DeleteApiKeyCommand("key-unknown"), "Admin")); actor.Tell(Envelope(new DeleteApiKeyCommand("key-unknown"), "Administrator"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
@@ -232,7 +232,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
public void CreateApiKey_EmptyMethods_ReturnsManagementError() public void CreateApiKey_EmptyMethods_ReturnsManagementError()
{ {
var actor = CreateActor(); var actor = CreateActor();
actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", Array.Empty<string>()), "Admin")); actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", Array.Empty<string>()), "Administrator"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
@@ -249,7 +249,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
_admin.Seed("key-1", "Service A", enabled: true, "M1"); _admin.Seed("key-1", "Service A", enabled: true, "M1");
var actor = CreateActor(); var actor = CreateActor();
actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-1", Array.Empty<string>()), "Admin")); actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-1", Array.Empty<string>()), "Administrator"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
@@ -265,11 +265,11 @@ public class ApiKeyCreationTests : TestKit, IDisposable
public void EveryApiKeyCommand_RequiresAdminRole(object command) public void EveryApiKeyCommand_RequiresAdminRole(object command)
{ {
var actor = CreateActor(); var actor = CreateActor();
// A Design-role caller (not Admin) must be rejected for every API-key command. // A Designer-role caller (not Administrator) must be rejected for every API-key command.
actor.Tell(Envelope(command, "Design")); actor.Tell(Envelope(command, "Designer"));
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Admin", response.Message); Assert.Contains("Administrator", response.Message);
} }
public static IEnumerable<object[]> AllApiKeyCommands() => new[] public static IEnumerable<object[]> AllApiKeyCommands() => new[]
@@ -125,7 +125,7 @@ public class AuditEndpointsTests
public async Task Query_ValidParams_ReturnsJsonPage() public async Task Query_ValidParams_ReturnsJsonPage()
{ {
var (client, _, host) = await BuildHostAsync( var (client, _, host) = await BuildHostAsync(
roles: new[] { "Audit" }, roles: new[] { "Administrator" },
queryPages: new[] { (IReadOnlyList<AuditEvent>)new[] { SampleEvent() } }); queryPages: new[] { (IReadOnlyList<AuditEvent>)new[] { SampleEvent() } });
using (host) using (host)
{ {
@@ -160,7 +160,7 @@ public class AuditEndpointsTests
new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc)), new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc)),
}; };
var (client, repo, host) = await BuildHostAsync( var (client, repo, host) = await BuildHostAsync(
roles: new[] { "Audit" }, roles: new[] { "Administrator" },
queryPages: new[] { pageOne }); queryPages: new[] { pageOne });
using (host) using (host)
{ {
@@ -193,9 +193,9 @@ public class AuditEndpointsTests
[Fact] [Fact]
public async Task Query_WithoutOperationalAudit_Returns403() public async Task Query_WithoutOperationalAudit_Returns403()
{ {
// A user whose only role is Design holds neither OperationalAudit nor // A user whose only role is Designer holds neither OperationalAudit nor
// AuditExport — the query endpoint must 403. // AuditExport — the query endpoint must 403.
var (client, _, host) = await BuildHostAsync(roles: new[] { "Design" }); var (client, _, host) = await BuildHostAsync(roles: new[] { "Designer" });
using (host) using (host)
{ {
var response = await client.SendAsync(Get("/api/audit/query")); var response = await client.SendAsync(Get("/api/audit/query"));
@@ -206,7 +206,7 @@ public class AuditEndpointsTests
[Fact] [Fact]
public async Task Query_WithoutCredentials_Returns401() public async Task Query_WithoutCredentials_Returns401()
{ {
var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" }); var (client, _, host) = await BuildHostAsync(roles: new[] { "Administrator" });
using (host) using (host)
{ {
var response = await client.SendAsync(Get("/api/audit/query", credential: "")); var response = await client.SendAsync(Get("/api/audit/query", credential: ""));
@@ -215,10 +215,11 @@ public class AuditEndpointsTests
} }
[Fact] [Fact]
public async Task Query_AuditReadOnlyRole_IsAllowed() public async Task Query_ViewerRole_IsAllowed()
{ {
// AuditReadOnly satisfies OperationalAudit (read) — query must succeed. // Viewer (post Task 1.7 home of the former AuditReadOnly role) satisfies
var (client, _, host) = await BuildHostAsync(roles: new[] { "AuditReadOnly" }); // OperationalAudit (read) — query must succeed.
var (client, _, host) = await BuildHostAsync(roles: new[] { "Viewer" });
using (host) using (host)
{ {
var response = await client.SendAsync(Get("/api/audit/query")); var response = await client.SendAsync(Get("/api/audit/query"));
@@ -234,7 +235,7 @@ public class AuditEndpointsTests
public async Task Export_Csv_StreamsContent_WithCsvContentType() public async Task Export_Csv_StreamsContent_WithCsvContentType()
{ {
var (client, _, host) = await BuildHostAsync( var (client, _, host) = await BuildHostAsync(
roles: new[] { "Audit" }, roles: new[] { "Administrator" },
queryPages: new[] queryPages: new[]
{ {
(IReadOnlyList<AuditEvent>)new[] { SampleEvent() }, (IReadOnlyList<AuditEvent>)new[] { SampleEvent() },
@@ -263,7 +264,7 @@ public class AuditEndpointsTests
{ {
// No format= param → csv default. // No format= param → csv default.
var (client, _, host) = await BuildHostAsync( var (client, _, host) = await BuildHostAsync(
roles: new[] { "Audit" }, roles: new[] { "Administrator" },
queryPages: new[] { (IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>() }); queryPages: new[] { (IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>() });
using (host) using (host)
{ {
@@ -277,7 +278,7 @@ public class AuditEndpointsTests
public async Task Export_Jsonl_StreamsOnePerLine() public async Task Export_Jsonl_StreamsOnePerLine()
{ {
var (client, _, host) = await BuildHostAsync( var (client, _, host) = await BuildHostAsync(
roles: new[] { "Audit" }, roles: new[] { "Administrator" },
queryPages: new[] queryPages: new[]
{ {
(IReadOnlyList<AuditEvent>)new[] (IReadOnlyList<AuditEvent>)new[]
@@ -313,7 +314,7 @@ public class AuditEndpointsTests
{ {
// Parquet archival is deferred to v1.x (Component-AuditLog.md) — no // Parquet archival is deferred to v1.x (Component-AuditLog.md) — no
// library is referenced, so the endpoint returns 501 with guidance. // library is referenced, so the endpoint returns 501 with guidance.
var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" }); var (client, _, host) = await BuildHostAsync(roles: new[] { "Administrator" });
using (host) using (host)
{ {
var response = await client.SendAsync(Get("/api/audit/export?format=parquet")); var response = await client.SendAsync(Get("/api/audit/export?format=parquet"));
@@ -327,9 +328,10 @@ public class AuditEndpointsTests
[Fact] [Fact]
public async Task Export_WithoutAuditExport_Returns403() public async Task Export_WithoutAuditExport_Returns403()
{ {
// AuditReadOnly grants read (OperationalAudit) but NOT bulk export // Viewer (former AuditReadOnly) grants read (OperationalAudit) but NOT
// (AuditExport) — the export endpoint must 403. // bulk export (AuditExport) — the export endpoint must 403. This is the
var (client, _, host) = await BuildHostAsync(roles: new[] { "AuditReadOnly" }); // preserved half-SoD after the Task 1.7 AuditReadOnly→Viewer collapse.
var (client, _, host) = await BuildHostAsync(roles: new[] { "Viewer" });
using (host) using (host)
{ {
var response = await client.SendAsync(Get("/api/audit/export?format=csv")); var response = await client.SendAsync(Get("/api/audit/export?format=csv"));
@@ -340,7 +342,7 @@ public class AuditEndpointsTests
[Fact] [Fact]
public async Task Export_UnsupportedFormat_Returns400() public async Task Export_UnsupportedFormat_Returns400()
{ {
var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" }); var (client, _, host) = await BuildHostAsync(roles: new[] { "Administrator" });
using (host) using (host)
{ {
var response = await client.SendAsync(Get("/api/audit/export?format=xml")); var response = await client.SendAsync(Get("/api/audit/export?format=xml"));
@@ -496,7 +498,7 @@ public class AuditEndpointsTests
{ {
// End-to-end: a repeated channel= query param must surface at the // End-to-end: a repeated channel= query param must surface at the
// repository as a two-element Channels list. // repository as a two-element Channels list.
var (client, repo, host) = await BuildHostAsync(roles: new[] { "Audit" }); var (client, repo, host) = await BuildHostAsync(roles: new[] { "Administrator" });
using (host) using (host)
{ {
var response = await client.SendAsync(Get( var response = await client.SendAsync(Get(
@@ -537,11 +539,11 @@ public class AuditEndpointsTests
[Fact] [Fact]
public void ApplySiteScope_SystemWideUser_ReturnsFilterUnchanged() public void ApplySiteScope_SystemWideUser_ReturnsFilterUnchanged()
{ {
// Empty PermittedSiteIds is the system-wide signal (Admin, system-wide // Empty PermittedSiteIds is the system-wide signal (Administrator,
// Deployment, audit roles with no scope rules attached). The filter // system-wide Deployer, audit roles with no scope rules attached). The
// should pass through with no restriction added. // filter should pass through with no restriction added.
var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser( var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "Admin" }, Array.Empty<string>()); "alice", "Alice", new[] { "Administrator" }, Array.Empty<string>());
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" }); var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" });
var result = AuditEndpoints.ApplySiteScope(filter, user); var result = AuditEndpoints.ApplySiteScope(filter, user);
@@ -557,7 +559,7 @@ public class AuditEndpointsTests
// the query to the user's permitted set, otherwise a site-scoped audit // the query to the user's permitted set, otherwise a site-scoped audit
// user could read every site's rows. // user could read every site's rows.
var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser( var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a", "plant-b" }); "alice", "Alice", new[] { "Viewer" }, new[] { "plant-a", "plant-b" });
var filter = new AuditLogQueryFilter(); var filter = new AuditLogQueryFilter();
var result = AuditEndpoints.ApplySiteScope(filter, user); var result = AuditEndpoints.ApplySiteScope(filter, user);
@@ -571,7 +573,7 @@ public class AuditEndpointsTests
public void ApplySiteScope_ScopedUser_ExplicitInScopeFilter_KeptVerbatim() public void ApplySiteScope_ScopedUser_ExplicitInScopeFilter_KeptVerbatim()
{ {
var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser( var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a", "plant-b" }); "alice", "Alice", new[] { "Viewer" }, new[] { "plant-a", "plant-b" });
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" }); var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" });
var result = AuditEndpoints.ApplySiteScope(filter, user); var result = AuditEndpoints.ApplySiteScope(filter, user);
@@ -586,7 +588,7 @@ public class AuditEndpointsTests
// Caller explicitly asked for a site they cannot see — the helper signals // Caller explicitly asked for a site they cannot see — the helper signals
// "403" by returning null rather than silently producing an empty page. // "403" by returning null rather than silently producing an empty page.
var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser( var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a" }); "alice", "Alice", new[] { "Viewer" }, new[] { "plant-a" });
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-b" }); var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-b" });
var result = AuditEndpoints.ApplySiteScope(filter, user); var result = AuditEndpoints.ApplySiteScope(filter, user);
@@ -598,7 +600,7 @@ public class AuditEndpointsTests
public void ApplySiteScope_ScopedUser_MixedInAndOutOfScopeFilter_IntersectedToInScopeOnly() public void ApplySiteScope_ScopedUser_MixedInAndOutOfScopeFilter_IntersectedToInScopeOnly()
{ {
var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser( var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a" }); "alice", "Alice", new[] { "Viewer" }, new[] { "plant-a" });
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a", "plant-b" }); var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a", "plant-b" });
var result = AuditEndpoints.ApplySiteScope(filter, user); var result = AuditEndpoints.ApplySiteScope(filter, user);
@@ -12,7 +12,7 @@ public class DebugStreamHubTests
public void IsInstanceAccessAllowed_SiteScopedUser_InScopeInstance_Allowed() public void IsInstanceAccessAllowed_SiteScopedUser_InScopeInstance_Allowed()
{ {
var allowed = DebugStreamHub.IsInstanceAccessAllowed( var allowed = DebugStreamHub.IsInstanceAccessAllowed(
roles: new[] { "Deployment" }, roles: new[] { "Deployer" },
permittedSiteIds: new[] { "1", "2" }, permittedSiteIds: new[] { "1", "2" },
instanceSiteId: 2); instanceSiteId: 2);
@@ -23,7 +23,7 @@ public class DebugStreamHubTests
public void IsInstanceAccessAllowed_SiteScopedUser_OutOfScopeInstance_Denied() public void IsInstanceAccessAllowed_SiteScopedUser_OutOfScopeInstance_Denied()
{ {
var allowed = DebugStreamHub.IsInstanceAccessAllowed( var allowed = DebugStreamHub.IsInstanceAccessAllowed(
roles: new[] { "Deployment" }, roles: new[] { "Deployer" },
permittedSiteIds: new[] { "1", "2" }, permittedSiteIds: new[] { "1", "2" },
instanceSiteId: 99); instanceSiteId: 99);
@@ -33,9 +33,9 @@ public class DebugStreamHubTests
[Fact] [Fact]
public void IsInstanceAccessAllowed_SystemWideDeployment_AnySiteAllowed() public void IsInstanceAccessAllowed_SystemWideDeployment_AnySiteAllowed()
{ {
// Empty permitted set == system-wide Deployment. // Empty permitted set == system-wide Deployer.
var allowed = DebugStreamHub.IsInstanceAccessAllowed( var allowed = DebugStreamHub.IsInstanceAccessAllowed(
roles: new[] { "Deployment" }, roles: new[] { "Deployer" },
permittedSiteIds: Array.Empty<string>(), permittedSiteIds: Array.Empty<string>(),
instanceSiteId: 99); instanceSiteId: 99);
@@ -46,7 +46,7 @@ public class DebugStreamHubTests
public void IsInstanceAccessAllowed_AdminRole_BypassesSiteScope() public void IsInstanceAccessAllowed_AdminRole_BypassesSiteScope()
{ {
var allowed = DebugStreamHub.IsInstanceAccessAllowed( var allowed = DebugStreamHub.IsInstanceAccessAllowed(
roles: new[] { "Admin", "Deployment" }, roles: new[] { "Administrator", "Deployer" },
permittedSiteIds: new[] { "1" }, permittedSiteIds: new[] { "1" },
instanceSiteId: 99); instanceSiteId: 99);
@@ -57,7 +57,7 @@ public class DebugStreamHubTests
public void IsInstanceAccessAllowed_AdminRoleCheck_IsCaseInsensitive() public void IsInstanceAccessAllowed_AdminRoleCheck_IsCaseInsensitive()
{ {
var allowed = DebugStreamHub.IsInstanceAccessAllowed( var allowed = DebugStreamHub.IsInstanceAccessAllowed(
roles: new[] { "admin" }, roles: new[] { "administrator" },
permittedSiteIds: new[] { "1" }, permittedSiteIds: new[] { "1" },
instanceSiteId: 99); instanceSiteId: 99);
@@ -61,12 +61,12 @@ public class ManagementActorTests : TestKit, IDisposable
public void CreateSiteCommand_WithDesignRole_ReturnsUnauthorized() public void CreateSiteCommand_WithDesignRole_ReturnsUnauthorized()
{ {
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new CreateSiteCommand("Site1", "SITE1", "Desc"), "Design"); var envelope = Envelope(new CreateSiteCommand("Site1", "SITE1", "Desc"), "Designer");
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Admin", response.Message); Assert.Contains("Administrator", response.Message);
Assert.Equal(envelope.CorrelationId, response.CorrelationId); Assert.Equal(envelope.CorrelationId, response.CorrelationId);
} }
@@ -79,19 +79,19 @@ public class ManagementActorTests : TestKit, IDisposable
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Admin", response.Message); Assert.Contains("Administrator", response.Message);
} }
[Fact] [Fact]
public void DeploymentCommand_WithDesignRole_ReturnsUnauthorized() public void DeploymentCommand_WithDesignRole_ReturnsUnauthorized()
{ {
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new CreateInstanceCommand("Inst1", 1, 1), "Design"); var envelope = Envelope(new CreateInstanceCommand("Inst1", 1, 1), "Designer");
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Deployment", response.Message); Assert.Contains("Deployer", response.Message);
} }
[Fact] [Fact]
@@ -109,19 +109,19 @@ public class ManagementActorTests : TestKit, IDisposable
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Admin", response.Message); Assert.Contains("Administrator", response.Message);
} }
[Fact] [Fact]
public void QueryAuditLogCommand_WithDeploymentRole_ReturnsUnauthorized() public void QueryAuditLogCommand_WithDeploymentRole_ReturnsUnauthorized()
{ {
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new QueryAuditLogCommand(null, null, null, null, null, 1, 25), "Deployment"); var envelope = Envelope(new QueryAuditLogCommand(null, null, null, null, null, 1, 25), "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Admin", response.Message); Assert.Contains("Administrator", response.Message);
} }
// ======================================================================== // ========================================================================
@@ -154,7 +154,7 @@ public class ManagementActorTests : TestKit, IDisposable
_services.AddScoped(_ => siteRepo); _services.AddScoped(_ => siteRepo);
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new ListSitesCommand(), "Design"); var envelope = Envelope(new ListSitesCommand(), "Designer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -220,7 +220,7 @@ public class ManagementActorTests : TestKit, IDisposable
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope( var envelope = Envelope(
new CreateInstanceCommand("Pump1", 1, 1), new CreateInstanceCommand("Pump1", 1, 1),
"Deployment"); "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -264,7 +264,7 @@ public class ManagementActorTests : TestKit, IDisposable
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope( var envelope = Envelope(
new CreateInstanceCommand("BadInst", 99, 1), new CreateInstanceCommand("BadInst", 99, 1),
"Deployment"); "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -280,16 +280,16 @@ public class ManagementActorTests : TestKit, IDisposable
[Fact] [Fact]
public void DesignCommand_WithAdminRole_ReturnsUnauthorized() public void DesignCommand_WithAdminRole_ReturnsUnauthorized()
{ {
// CreateTemplateCommand requires "Design" role, "Admin" alone is insufficient // CreateTemplateCommand requires "Designer" role, "Administrator" alone is insufficient
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope( var envelope = Envelope(
new CreateTemplateCommand("T1", null, null), new CreateTemplateCommand("T1", null, null),
"Admin"); "Administrator");
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Design", response.Message); Assert.Contains("Designer", response.Message);
} }
[Fact] [Fact]
@@ -305,7 +305,7 @@ public class ManagementActorTests : TestKit, IDisposable
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope( var envelope = Envelope(
new CreateSiteCommand("NewSite", "NS1", "Desc"), new CreateSiteCommand("NewSite", "NS1", "Desc"),
"Admin"); "Administrator");
actor.Tell(envelope); actor.Tell(envelope);
@@ -324,10 +324,10 @@ public class ManagementActorTests : TestKit, IDisposable
_services.AddScoped(_ => siteRepo); _services.AddScoped(_ => siteRepo);
var actor = CreateActor(); var actor = CreateActor();
// "admin" lowercase should still match "Admin" requirement // "administrator" lowercase should still match "Administrator" requirement
var envelope = Envelope( var envelope = Envelope(
new CreateSiteCommand("Site2", "S2", null), new CreateSiteCommand("Site2", "S2", null),
"admin"); "administrator");
actor.Tell(envelope); actor.Tell(envelope);
@@ -343,84 +343,84 @@ public class ManagementActorTests : TestKit, IDisposable
public void SharedScriptCreate_WithAdminRole_ReturnsUnauthorized() public void SharedScriptCreate_WithAdminRole_ReturnsUnauthorized()
{ {
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new CreateSharedScriptCommand("Script1", "code", null, null), "Admin"); var envelope = Envelope(new CreateSharedScriptCommand("Script1", "code", null, null), "Administrator");
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Design", response.Message); Assert.Contains("Designer", response.Message);
} }
[Fact] [Fact]
public void DatabaseConnectionCreate_WithDeploymentRole_ReturnsUnauthorized() public void DatabaseConnectionCreate_WithDeploymentRole_ReturnsUnauthorized()
{ {
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new CreateDatabaseConnectionDefCommand("DB1", "Server=test"), "Deployment"); var envelope = Envelope(new CreateDatabaseConnectionDefCommand("DB1", "Server=test"), "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Design", response.Message); Assert.Contains("Designer", response.Message);
} }
[Fact] [Fact]
public void ApiMethodCreate_WithAdminRole_ReturnsUnauthorized() public void ApiMethodCreate_WithAdminRole_ReturnsUnauthorized()
{ {
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new CreateApiMethodCommand("Method1", "code", 30, null, null), "Admin"); var envelope = Envelope(new CreateApiMethodCommand("Method1", "code", 30, null, null), "Administrator");
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Design", response.Message); Assert.Contains("Designer", response.Message);
} }
[Fact] [Fact]
public void AddTemplateAttribute_WithDeploymentRole_ReturnsUnauthorized() public void AddTemplateAttribute_WithDeploymentRole_ReturnsUnauthorized()
{ {
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new AddTemplateAttributeCommand(1, "Attr1", "Float", null, null, null, false), "Deployment"); var envelope = Envelope(new AddTemplateAttributeCommand(1, "Attr1", "Float", null, null, null, false), "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Design", response.Message); Assert.Contains("Designer", response.Message);
} }
[Fact] [Fact]
public void UpdateApiKey_WithDesignRole_ReturnsUnauthorized() public void UpdateApiKey_WithDesignRole_ReturnsUnauthorized()
{ {
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new UpdateApiKeyCommand("key-1", true), "Design"); var envelope = Envelope(new UpdateApiKeyCommand("key-1", true), "Designer");
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Admin", response.Message); Assert.Contains("Administrator", response.Message);
} }
[Fact] [Fact]
public void AddScopeRule_WithDesignRole_ReturnsUnauthorized() public void AddScopeRule_WithDesignRole_ReturnsUnauthorized()
{ {
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new AddScopeRuleCommand(1, 1), "Design"); var envelope = Envelope(new AddScopeRuleCommand(1, 1), "Designer");
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Admin", response.Message); Assert.Contains("Administrator", response.Message);
} }
[Fact] [Fact]
public void UpdateArea_WithAdminRole_ReturnsUnauthorized() public void UpdateArea_WithAdminRole_ReturnsUnauthorized()
{ {
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new UpdateAreaCommand(1, "NewName"), "Admin"); var envelope = Envelope(new UpdateAreaCommand(1, "NewName"), "Administrator");
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Design", response.Message); Assert.Contains("Designer", response.Message);
} }
// ======================================================================== // ========================================================================
@@ -486,7 +486,7 @@ public class ManagementActorTests : TestKit, IDisposable
_services.AddScoped(_ => secRepo); _services.AddScoped(_ => secRepo);
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new ListScopeRulesCommand(1), "Admin"); var envelope = Envelope(new ListScopeRulesCommand(1), "Administrator");
actor.Tell(envelope); actor.Tell(envelope);
@@ -545,7 +545,7 @@ public class ManagementActorTests : TestKit, IDisposable
.Returns(new Instance("Pump7") { Id = 7, SiteId = 2 }); .Returns(new Instance("Pump7") { Id = 7, SiteId = 2 });
var actor = CreateActor(); var actor = CreateActor();
var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployment"); var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -560,7 +560,7 @@ public class ManagementActorTests : TestKit, IDisposable
.Returns(new Instance("Pump7") { Id = 7, SiteId = 1 }); .Returns(new Instance("Pump7") { Id = 7, SiteId = 1 });
var actor = CreateActor(); var actor = CreateActor();
var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployment"); var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -573,7 +573,7 @@ public class ManagementActorTests : TestKit, IDisposable
AddSiteRepoWithSite(2, "SITE2"); AddSiteRepoWithSite(2, "SITE2");
var actor = CreateActor(); var actor = CreateActor();
var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Deployment"); var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -591,7 +591,7 @@ public class ManagementActorTests : TestKit, IDisposable
_services.AddScoped(_ => uiRepo); _services.AddScoped(_ => uiRepo);
var actor = CreateActor(); var actor = CreateActor();
var envelope = ScopedEnvelope(new ListAreasCommand(2), new[] { "1" }, "Deployment"); var envelope = ScopedEnvelope(new ListAreasCommand(2), new[] { "1" }, "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -608,7 +608,7 @@ public class ManagementActorTests : TestKit, IDisposable
_services.AddScoped(_ => siteRepo); _services.AddScoped(_ => siteRepo);
var actor = CreateActor(); var actor = CreateActor();
var envelope = ScopedEnvelope(new GetDataConnectionCommand(5), new[] { "1" }, "Deployment"); var envelope = ScopedEnvelope(new GetDataConnectionCommand(5), new[] { "1" }, "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -623,7 +623,7 @@ public class ManagementActorTests : TestKit, IDisposable
AddSiteRepoWithSite(2, "SITE2"); AddSiteRepoWithSite(2, "SITE2");
var actor = CreateActor(); var actor = CreateActor();
var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Admin"); var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Administrator");
actor.Tell(envelope); actor.Tell(envelope);
@@ -637,7 +637,7 @@ public class ManagementActorTests : TestKit, IDisposable
AddSiteRepoWithSite(2, "SITE2"); AddSiteRepoWithSite(2, "SITE2");
var actor = CreateActor(); var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryEventLogsCommand("SITE2"), new[] { "1" }, "Deployment"); var envelope = ScopedEnvelope(new QueryEventLogsCommand("SITE2"), new[] { "1" }, "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -651,7 +651,7 @@ public class ManagementActorTests : TestKit, IDisposable
AddSiteRepoWithSite(2, "SITE2"); AddSiteRepoWithSite(2, "SITE2");
var actor = CreateActor(); var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryParkedMessagesCommand("SITE2"), new[] { "1" }, "Deployment"); var envelope = ScopedEnvelope(new QueryParkedMessagesCommand("SITE2"), new[] { "1" }, "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -665,7 +665,7 @@ public class ManagementActorTests : TestKit, IDisposable
AddSiteRepoWithSite(2, "SITE2"); AddSiteRepoWithSite(2, "SITE2");
var actor = CreateActor(); var actor = CreateActor();
var envelope = ScopedEnvelope(new RetryParkedMessageCommand("SITE2", "msg-1"), new[] { "1" }, "Deployment"); var envelope = ScopedEnvelope(new RetryParkedMessageCommand("SITE2", "msg-1"), new[] { "1" }, "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -679,7 +679,7 @@ public class ManagementActorTests : TestKit, IDisposable
AddSiteRepoWithSite(2, "SITE2"); AddSiteRepoWithSite(2, "SITE2");
var actor = CreateActor(); var actor = CreateActor();
var envelope = ScopedEnvelope(new DiscardParkedMessageCommand("SITE2", "msg-1"), new[] { "1" }, "Deployment"); var envelope = ScopedEnvelope(new DiscardParkedMessageCommand("SITE2", "msg-1"), new[] { "1" }, "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -696,7 +696,7 @@ public class ManagementActorTests : TestKit, IDisposable
AddSiteRepoWithSite(2, "SITE2"); AddSiteRepoWithSite(2, "SITE2");
var actor = CreateActor(); var actor = CreateActor();
var envelope = ScopedEnvelope(new DebugSnapshotCommand(9), new[] { "1" }, "Deployment"); var envelope = ScopedEnvelope(new DebugSnapshotCommand(9), new[] { "1" }, "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -808,12 +808,12 @@ public class ManagementActorTests : TestKit, IDisposable
public void QueryDeployments_WithDesignRole_ReturnsUnauthorized() public void QueryDeployments_WithDesignRole_ReturnsUnauthorized()
{ {
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new QueryDeploymentsCommand(), "Design"); var envelope = Envelope(new QueryDeploymentsCommand(), "Designer");
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Deployment", response.Message); Assert.Contains("Deployer", response.Message);
} }
[Fact] [Fact]
@@ -828,7 +828,7 @@ public class ManagementActorTests : TestKit, IDisposable
_services.AddScoped(_ => deployRepo); _services.AddScoped(_ => deployRepo);
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new QueryDeploymentsCommand(), "Deployment"); var envelope = Envelope(new QueryDeploymentsCommand(), "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -846,7 +846,7 @@ public class ManagementActorTests : TestKit, IDisposable
_services.AddScoped(_ => deployRepo); _services.AddScoped(_ => deployRepo);
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new QueryDeploymentsCommand(InstanceId: 5), "Deployment"); var envelope = Envelope(new QueryDeploymentsCommand(InstanceId: 5), "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -864,7 +864,7 @@ public class ManagementActorTests : TestKit, IDisposable
_services.AddScoped(_ => deployRepo); _services.AddScoped(_ => deployRepo);
var actor = CreateActor(); var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(InstanceId: 5), new[] { "1" }, "Deployment"); var envelope = ScopedEnvelope(new QueryDeploymentsCommand(InstanceId: 5), new[] { "1" }, "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -885,7 +885,7 @@ public class ManagementActorTests : TestKit, IDisposable
_services.AddScoped(_ => deployRepo); _services.AddScoped(_ => deployRepo);
var actor = CreateActor(); var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(InstanceId: 5), new[] { "1" }, "Deployment"); var envelope = ScopedEnvelope(new QueryDeploymentsCommand(InstanceId: 5), new[] { "1" }, "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -914,7 +914,7 @@ public class ManagementActorTests : TestKit, IDisposable
_services.AddScoped(_ => deployRepo); _services.AddScoped(_ => deployRepo);
var actor = CreateActor(); var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployment"); var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -951,7 +951,7 @@ public class ManagementActorTests : TestKit, IDisposable
_services.AddScoped(_ => deployRepo); _services.AddScoped(_ => deployRepo);
var actor = CreateActor(); var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployment"); var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -974,7 +974,7 @@ public class ManagementActorTests : TestKit, IDisposable
_services.AddScoped(_ => deployRepo); _services.AddScoped(_ => deployRepo);
var actor = CreateActor(); var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Admin", "Deployment"); var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Administrator", "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -1006,7 +1006,7 @@ public class ManagementActorTests : TestKit, IDisposable
// "Good" is valid, "Bogus" is not — the whole command must fail with // "Good" is valid, "Bogus" is not — the whole command must fail with
// nothing written. // nothing written.
var overrides = new Dictionary<string, string?> { ["Good"] = "1", ["Bogus"] = "2" }; var overrides = new Dictionary<string, string?> { ["Good"] = "1", ["Bogus"] = "2" };
var envelope = Envelope(new SetInstanceOverridesCommand(3, overrides), "Deployment"); var envelope = Envelope(new SetInstanceOverridesCommand(3, overrides), "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -1036,7 +1036,7 @@ public class ManagementActorTests : TestKit, IDisposable
var actor = CreateActor(); var actor = CreateActor();
var overrides = new Dictionary<string, string?> { ["A"] = "1", ["B"] = "2" }; var overrides = new Dictionary<string, string?> { ["A"] = "1", ["B"] = "2" };
var envelope = Envelope(new SetInstanceOverridesCommand(4, overrides), "Deployment"); var envelope = Envelope(new SetInstanceOverridesCommand(4, overrides), "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -1095,7 +1095,7 @@ public class ManagementActorTests : TestKit, IDisposable
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope( var envelope = Envelope(
new UpdateSmtpConfigCommand(1, "new.example.com", 465, "Basic", "new@example.com", "SSL", "user:pass"), new UpdateSmtpConfigCommand(1, "new.example.com", 465, "Basic", "new@example.com", "SSL", "user:pass"),
"Design"); "Designer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -1125,7 +1125,7 @@ public class ManagementActorTests : TestKit, IDisposable
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope( var envelope = Envelope(
new UpdateSmtpConfigCommand(1, "new.example.com", 465, "Basic", "new@example.com"), new UpdateSmtpConfigCommand(1, "new.example.com", 465, "Basic", "new@example.com"),
"Design"); "Designer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -1148,7 +1148,7 @@ public class ManagementActorTests : TestKit, IDisposable
.Returns((Template?)null); .Returns((Template?)null);
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new CreateInstanceCommand("BadInst", 99, 1), "Deployment"); var envelope = Envelope(new CreateInstanceCommand("BadInst", 99, 1), "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -1228,12 +1228,12 @@ public class ManagementActorTests : TestKit, IDisposable
// ExportBundle requires the Design role; an Admin-only caller is rejected. // ExportBundle requires the Design role; an Admin-only caller is rejected.
AddBundleSubstitutes(); AddBundleSubstitutes();
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(AllExportCommand(), "Admin"); var envelope = Envelope(AllExportCommand(), "Administrator");
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Design", response.Message); Assert.Contains("Designer", response.Message);
} }
[Fact] [Fact]
@@ -1244,12 +1244,12 @@ public class ManagementActorTests : TestKit, IDisposable
// configuration). // configuration).
AddBundleSubstitutes(); AddBundleSubstitutes();
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new PreviewBundleCommand("AA==", null), "Design"); var envelope = Envelope(new PreviewBundleCommand("AA==", null), "Designer");
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Admin", response.Message); Assert.Contains("Administrator", response.Message);
} }
[Fact] [Fact]
@@ -1257,12 +1257,12 @@ public class ManagementActorTests : TestKit, IDisposable
{ {
AddBundleSubstitutes(); AddBundleSubstitutes();
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new ImportBundleCommand("AA==", null, "skip"), "Design"); var envelope = Envelope(new ImportBundleCommand("AA==", null, "skip"), "Designer");
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Admin", response.Message); Assert.Contains("Administrator", response.Message);
} }
[Fact] [Fact]
@@ -1286,7 +1286,7 @@ public class ManagementActorTests : TestKit, IDisposable
SourceEnvironment: "test-env"); SourceEnvironment: "test-env");
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(cmd, "Design"); var envelope = Envelope(cmd, "Designer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -1331,7 +1331,7 @@ public class ManagementActorTests : TestKit, IDisposable
// base64 check before reaching the importer. // base64 check before reaching the importer.
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 }); var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new ImportBundleCommand(payload, null, "skip"), "Admin"); var envelope = Envelope(new ImportBundleCommand(payload, null, "skip"), "Administrator");
actor.Tell(envelope); actor.Tell(envelope);
@@ -1399,7 +1399,7 @@ public class ManagementActorTests : TestKit, IDisposable
var actor = CreateActor(); var actor = CreateActor();
// "overwrite" policy so the final (Identical) row would otherwise differ // "overwrite" policy so the final (Identical) row would otherwise differ
// from the Modified row's action — proves the last-write-wins semantics. // from the Modified row's action — proves the last-write-wins semantics.
var envelope = Envelope(new ImportBundleCommand(payload, null, "overwrite"), "Admin"); var envelope = Envelope(new ImportBundleCommand(payload, null, "overwrite"), "Administrator");
actor.Tell(envelope); actor.Tell(envelope);
@@ -1425,7 +1425,7 @@ public class ManagementActorTests : TestKit, IDisposable
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope( var envelope = Envelope(
new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, "desc", false), new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, "desc", false),
"Design"); "Designer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -1440,12 +1440,12 @@ public class ManagementActorTests : TestKit, IDisposable
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope( var envelope = Envelope(
new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, null, false), new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, null, false),
"Deployment"); "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Design", response.Message); Assert.Contains("Designer", response.Message);
} }
[Fact] [Fact]
@@ -1473,7 +1473,7 @@ public class ManagementActorTests : TestKit, IDisposable
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope( var envelope = Envelope(
new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null), new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null),
"Deployment"); "Deployer");
actor.Tell(envelope); actor.Tell(envelope);
@@ -1488,11 +1488,11 @@ public class ManagementActorTests : TestKit, IDisposable
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope( var envelope = Envelope(
new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null), new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null),
"Design"); "Designer");
actor.Tell(envelope); actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Deployment", response.Message); Assert.Contains("Deployer", response.Message);
} }
} }
@@ -68,8 +68,8 @@ public class ScadaBridgeGroupRoleMapperTests
// Two matched mappings: an Admin group and a site-scoped Deployment group. // Two matched mappings: an Admin group and a site-scoped Deployment group.
var mappings = new List<LdapGroupMapping> var mappings = new List<LdapGroupMapping>
{ {
Mapping(1, "SCADA-Admins", Roles.Admin), Mapping(1, "SCADA-Admins", Roles.Administrator),
Mapping(2, "SiteDeployers", Roles.Deployment), Mapping(2, "SiteDeployers", Roles.Deployer),
}; };
var scopeRules = new Dictionary<int, IReadOnlyList<SiteScopeRule>> var scopeRules = new Dictionary<int, IReadOnlyList<SiteScopeRule>>
{ {
@@ -91,8 +91,8 @@ public class ScadaBridgeGroupRoleMapperTests
// Roles: same set as RoleMapper. // Roles: same set as RoleMapper.
Assert.Equal(expected.Roles.OrderBy(r => r), mapping.Roles.OrderBy(r => r)); Assert.Equal(expected.Roles.OrderBy(r => r), mapping.Roles.OrderBy(r => r));
Assert.Contains(Roles.Admin, mapping.Roles); Assert.Contains(Roles.Administrator, mapping.Roles);
Assert.Contains(Roles.Deployment, mapping.Roles); Assert.Contains(Roles.Deployer, mapping.Roles);
// Scope: carries the full RoleMappingResult (no site-scope info lost). // Scope: carries the full RoleMappingResult (no site-scope info lost).
var scope = Assert.IsType<RoleMappingResult>(mapping.Scope); var scope = Assert.IsType<RoleMappingResult>(mapping.Scope);
@@ -109,7 +109,7 @@ public class ScadaBridgeGroupRoleMapperTests
// Unscoped Deployment mapping -> system-wide, empty PermittedSiteIds. // Unscoped Deployment mapping -> system-wide, empty PermittedSiteIds.
var mappings = new List<LdapGroupMapping> var mappings = new List<LdapGroupMapping>
{ {
Mapping(1, "GlobalDeployers", Roles.Deployment), Mapping(1, "GlobalDeployers", Roles.Deployer),
}; };
var repo = new FakeSecurityRepository(mappings, new Dictionary<int, IReadOnlyList<SiteScopeRule>>()); var repo = new FakeSecurityRepository(mappings, new Dictionary<int, IReadOnlyList<SiteScopeRule>>());
var roleMapper = new RoleMapper(repo); var roleMapper = new RoleMapper(repo);
@@ -117,7 +117,7 @@ public class ScadaBridgeGroupRoleMapperTests
var mapping = await sut.MapAsync(new[] { "GlobalDeployers" }, CancellationToken.None); var mapping = await sut.MapAsync(new[] { "GlobalDeployers" }, CancellationToken.None);
Assert.Contains(Roles.Deployment, mapping.Roles); Assert.Contains(Roles.Deployer, mapping.Roles);
var scope = Assert.IsType<RoleMappingResult>(mapping.Scope); var scope = Assert.IsType<RoleMappingResult>(mapping.Scope);
Assert.True(scope.IsSystemWideDeployment); Assert.True(scope.IsSystemWideDeployment);
Assert.Empty(scope.PermittedSiteIds); Assert.Empty(scope.PermittedSiteIds);
@@ -134,7 +134,7 @@ public class ScadaBridgeGroupRoleMapperTests
// shared LDAP service fail-closes a zero-GROUP LDAP result before it ever reaches // shared LDAP service fail-closes a zero-GROUP LDAP result before it ever reaches
// the mapper. // the mapper.
var repo = new FakeSecurityRepository( var repo = new FakeSecurityRepository(
new List<LdapGroupMapping> { Mapping(1, "SCADA-Admins", Roles.Admin) }, new List<LdapGroupMapping> { Mapping(1, "SCADA-Admins", Roles.Administrator) },
new Dictionary<int, IReadOnlyList<SiteScopeRule>>()); new Dictionary<int, IReadOnlyList<SiteScopeRule>>());
var sut = new ScadaBridgeGroupRoleMapper(new RoleMapper(repo)); var sut = new ScadaBridgeGroupRoleMapper(new RoleMapper(repo));
@@ -154,7 +154,7 @@ public class ScadaBridgeGroupRoleMapperTests
// yields zero roles (not an error) — the mapper is the authoritative empty-roles // yields zero roles (not an error) — the mapper is the authoritative empty-roles
// boundary now that the LDAP service no longer admits zero-group successes. // boundary now that the LDAP service no longer admits zero-group successes.
var repo = new FakeSecurityRepository( var repo = new FakeSecurityRepository(
new List<LdapGroupMapping> { Mapping(1, "SCADA-Admins", Roles.Admin) }, new List<LdapGroupMapping> { Mapping(1, "SCADA-Admins", Roles.Administrator) },
new Dictionary<int, IReadOnlyList<SiteScopeRule>>()); new Dictionary<int, IReadOnlyList<SiteScopeRule>>());
var sut = new ScadaBridgeGroupRoleMapper(new RoleMapper(repo)); var sut = new ScadaBridgeGroupRoleMapper(new RoleMapper(repo));
@@ -116,7 +116,7 @@ public class JwtTokenServiceTests
var service = CreateService(); var service = CreateService();
var token = service.GenerateToken( var token = service.GenerateToken(
"John Doe", "johnd", "John Doe", "johnd",
new[] { "Admin", "Design" }, new[] { "Administrator", "Designer" },
new[] { "1", "2" }); new[] { "1", "2" });
var principal = service.ValidateToken(token); var principal = service.ValidateToken(token);
@@ -126,8 +126,8 @@ public class JwtTokenServiceTests
Assert.Equal("johnd", principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value); Assert.Equal("johnd", principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value);
var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList(); var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
Assert.Contains("Admin", roles); Assert.Contains("Administrator", roles);
Assert.Contains("Design", roles); Assert.Contains("Designer", roles);
var siteIds = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToList(); var siteIds = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToList();
Assert.Contains("1", siteIds); Assert.Contains("1", siteIds);
@@ -140,7 +140,7 @@ public class JwtTokenServiceTests
public void GenerateToken_NullSiteIds_NoSiteIdClaims() public void GenerateToken_NullSiteIds_NoSiteIdClaims()
{ {
var service = CreateService(); var service = CreateService();
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
var principal = service.ValidateToken(token); var principal = service.ValidateToken(token);
Assert.NotNull(principal); Assert.NotNull(principal);
@@ -159,7 +159,7 @@ public class JwtTokenServiceTests
public void ValidateToken_WrongKey_ReturnsNull() public void ValidateToken_WrongKey_ReturnsNull()
{ {
var service1 = CreateService(); var service1 = CreateService();
var token = service1.GenerateToken("User", "user", new[] { "Admin" }, null); var token = service1.GenerateToken("User", "user", new[] { "Administrator" }, null);
var service2 = CreateService(new SecurityOptions var service2 = CreateService(new SecurityOptions
{ {
@@ -176,7 +176,7 @@ public class JwtTokenServiceTests
public void ValidateToken_UsesHmacSha256() public void ValidateToken_UsesHmacSha256()
{ {
var service = CreateService(); var service = CreateService();
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
// Decode header to verify algorithm // Decode header to verify algorithm
var parts = token.Split('.'); var parts = token.Split('.');
@@ -192,7 +192,7 @@ public class JwtTokenServiceTests
options.JwtExpiryMinutes = 3; // Token expires in 3 min, threshold is 5 min options.JwtExpiryMinutes = 3; // Token expires in 3 min, threshold is 5 min
var service = CreateService(options); var service = CreateService(options);
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
var principal = service.ValidateToken(token); var principal = service.ValidateToken(token);
Assert.True(service.ShouldRefresh(principal!)); Assert.True(service.ShouldRefresh(principal!));
@@ -202,7 +202,7 @@ public class JwtTokenServiceTests
public void ShouldRefresh_TokenFarFromExpiry_ReturnsFalse() public void ShouldRefresh_TokenFarFromExpiry_ReturnsFalse()
{ {
var service = CreateService(); // 15 min expiry, 5 min threshold var service = CreateService(); // 15 min expiry, 5 min threshold
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
var principal = service.ValidateToken(token); var principal = service.ValidateToken(token);
Assert.False(service.ShouldRefresh(principal!)); Assert.False(service.ShouldRefresh(principal!));
@@ -212,7 +212,7 @@ public class JwtTokenServiceTests
public void IsIdleTimedOut_RecentActivity_ReturnsFalse() public void IsIdleTimedOut_RecentActivity_ReturnsFalse()
{ {
var service = CreateService(); var service = CreateService();
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
var principal = service.ValidateToken(token); var principal = service.ValidateToken(token);
Assert.False(service.IsIdleTimedOut(principal!)); Assert.False(service.IsIdleTimedOut(principal!));
@@ -234,15 +234,15 @@ public class JwtTokenServiceTests
public void RefreshToken_ReturnsNewTokenWithUpdatedClaims() public void RefreshToken_ReturnsNewTokenWithUpdatedClaims()
{ {
var service = CreateService(); var service = CreateService();
var originalToken = service.GenerateToken("User", "user", new[] { "Admin" }, null); var originalToken = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
var principal = service.ValidateToken(originalToken); var principal = service.ValidateToken(originalToken);
var newToken = service.RefreshToken(principal!, new[] { "Admin", "Design" }, new[] { "1" }); var newToken = service.RefreshToken(principal!, new[] { "Administrator", "Designer" }, new[] { "1" });
Assert.NotNull(newToken); Assert.NotNull(newToken);
var newPrincipal = service.ValidateToken(newToken!); var newPrincipal = service.ValidateToken(newToken!);
var roles = newPrincipal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList(); var roles = newPrincipal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
Assert.Contains("Design", roles); Assert.Contains("Designer", roles);
} }
[Fact] [Fact]
@@ -251,7 +251,7 @@ public class JwtTokenServiceTests
var service = CreateService(); var service = CreateService();
var principal = new ClaimsPrincipal(new ClaimsIdentity()); var principal = new ClaimsPrincipal(new ClaimsIdentity());
var result = service.RefreshToken(principal, new[] { "Admin" }, null); var result = service.RefreshToken(principal, new[] { "Administrator" }, null);
Assert.Null(result); Assert.Null(result);
} }
} }
@@ -288,16 +288,16 @@ public class RoleMapperTests : IDisposable
[Fact] [Fact]
public async Task MapGroupsToRoles_MultiRoleExtraction() public async Task MapGroupsToRoles_MultiRoleExtraction()
{ {
// Add mappings (note: seed data adds SCADA-Admins -> Admin) // Add mappings (note: seed data adds SCADA-Admins -> Administrator)
_context.LdapGroupMappings.Add(new LdapGroupMapping("Designers", "Design")); _context.LdapGroupMappings.Add(new LdapGroupMapping("Designers", Roles.Designer));
_context.LdapGroupMappings.Add(new LdapGroupMapping("Deployers", "Deployment")); _context.LdapGroupMappings.Add(new LdapGroupMapping("Deployers", Roles.Deployer));
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Admins", "Designers" }); var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Admins", "Designers" });
Assert.Contains("Admin", result.Roles); Assert.Contains(Roles.Administrator, result.Roles);
Assert.Contains("Design", result.Roles); Assert.Contains(Roles.Designer, result.Roles);
Assert.DoesNotContain("Deployment", result.Roles); Assert.DoesNotContain(Roles.Deployer, result.Roles);
} }
[Fact] [Fact]
@@ -306,7 +306,7 @@ public class RoleMapperTests : IDisposable
var site1 = new Site("Site1", "S-001"); var site1 = new Site("Site1", "S-001");
var site2 = new Site("Site2", "S-002"); var site2 = new Site("Site2", "S-002");
_context.Sites.AddRange(site1, site2); _context.Sites.AddRange(site1, site2);
_context.LdapGroupMappings.Add(new LdapGroupMapping("SiteDeployers", "Deployment")); _context.LdapGroupMappings.Add(new LdapGroupMapping("SiteDeployers", Roles.Deployer));
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
var mapping = await _context.LdapGroupMappings.SingleAsync(m => m.LdapGroupName == "SiteDeployers"); var mapping = await _context.LdapGroupMappings.SingleAsync(m => m.LdapGroupName == "SiteDeployers");
@@ -317,7 +317,7 @@ public class RoleMapperTests : IDisposable
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SiteDeployers" }); var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SiteDeployers" });
Assert.Contains("Deployment", result.Roles); Assert.Contains(Roles.Deployer, result.Roles);
Assert.False(result.IsSystemWideDeployment); Assert.False(result.IsSystemWideDeployment);
Assert.Contains(site1.Id.ToString(), result.PermittedSiteIds); Assert.Contains(site1.Id.ToString(), result.PermittedSiteIds);
Assert.Contains(site2.Id.ToString(), result.PermittedSiteIds); Assert.Contains(site2.Id.ToString(), result.PermittedSiteIds);
@@ -326,8 +326,8 @@ public class RoleMapperTests : IDisposable
[Fact] [Fact]
public async Task MapGroupsToRoles_UserInBothSystemWideAndScopedDeploymentGroup_IsSystemWide() public async Task MapGroupsToRoles_UserInBothSystemWideAndScopedDeploymentGroup_IsSystemWide()
{ {
// Security-016: a user in BOTH an unscoped Deployment mapping // Security-016: a user in BOTH an unscoped Deployer mapping
// (SCADA-Deploy-All, Id=3) AND a scoped Deployment mapping // (SCADA-Deploy-All, Id=3) AND a scoped Deployer mapping
// (SCADA-Deploy-SiteA, Id=4) used to be silently narrowed to the site-A // (SCADA-Deploy-SiteA, Id=4) used to be silently narrowed to the site-A
// grant. The union semantics now preserve the broader grant: the // grant. The union semantics now preserve the broader grant: the
// unscoped mapping wins, PermittedSiteIds is empty, system-wide. // unscoped mapping wins, PermittedSiteIds is empty, system-wide.
@@ -341,7 +341,7 @@ public class RoleMapperTests : IDisposable
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Deploy-All", "SCADA-Deploy-SiteA" }); var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Deploy-All", "SCADA-Deploy-SiteA" });
Assert.Contains("Deployment", result.Roles); Assert.Contains(Roles.Deployer, result.Roles);
Assert.True(result.IsSystemWideDeployment); Assert.True(result.IsSystemWideDeployment);
Assert.Empty(result.PermittedSiteIds); Assert.Empty(result.PermittedSiteIds);
} }
@@ -349,12 +349,12 @@ public class RoleMapperTests : IDisposable
[Fact] [Fact]
public async Task MapGroupsToRoles_SystemWideDeployment_NoScopeRules() public async Task MapGroupsToRoles_SystemWideDeployment_NoScopeRules()
{ {
_context.LdapGroupMappings.Add(new LdapGroupMapping("GlobalDeployers", "Deployment")); _context.LdapGroupMappings.Add(new LdapGroupMapping("GlobalDeployers", Roles.Deployer));
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "GlobalDeployers" }); var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "GlobalDeployers" });
Assert.Contains("Deployment", result.Roles); Assert.Contains(Roles.Deployer, result.Roles);
Assert.True(result.IsSystemWideDeployment); Assert.True(result.IsSystemWideDeployment);
Assert.Empty(result.PermittedSiteIds); Assert.Empty(result.PermittedSiteIds);
} }
@@ -380,10 +380,10 @@ public class RoleMapperTests : IDisposable
[Fact] [Fact]
public async Task MapGroupsToRoles_CaseInsensitiveGroupMatch() public async Task MapGroupsToRoles_CaseInsensitiveGroupMatch()
{ {
// "SCADA-Admins" is seeded // "SCADA-Admins" is seeded (role canonicalized to Administrator, Task 1.7)
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "scada-admins" }); var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "scada-admins" });
Assert.Contains("Admin", result.Roles); Assert.Contains(Roles.Administrator, result.Roles);
} }
} }
@@ -430,7 +430,7 @@ public class SecurityReviewRegressionTests
{ {
var key = new string('k', 32); var key = new string('k', 32);
var service = new JwtTokenService(Options.Create(JwtOptions(key)), NullLogger<JwtTokenService>.Instance); var service = new JwtTokenService(Options.Create(JwtOptions(key)), NullLogger<JwtTokenService>.Instance);
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
Assert.False(string.IsNullOrEmpty(token)); Assert.False(string.IsNullOrEmpty(token));
} }
@@ -610,7 +610,7 @@ public class SecurityReviewRegressionTests2
public void GenerateToken_SetsIssuerAndAudience() public void GenerateToken_SetsIssuerAndAudience()
{ {
var service = CreateJwtService(); var service = CreateJwtService();
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
var jwt = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().ReadJwtToken(token); var jwt = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().ReadJwtToken(token);
Assert.Equal(JwtTokenService.TokenIssuer, jwt.Issuer); Assert.Equal(JwtTokenService.TokenIssuer, jwt.Issuer);
@@ -642,7 +642,7 @@ public class SecurityReviewRegressionTests2
public void ValidateToken_AcceptsOwnIssuerAndAudience() public void ValidateToken_AcceptsOwnIssuerAndAudience()
{ {
var service = CreateJwtService(); var service = CreateJwtService();
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
Assert.NotNull(service.ValidateToken(token)); Assert.NotNull(service.ValidateToken(token));
} }
@@ -663,7 +663,7 @@ public class SecurityReviewRegressionTests2
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
}, "test")); }, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null); var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
Assert.NotNull(refreshed); Assert.NotNull(refreshed);
var refreshedPrincipal = service.ValidateToken(refreshed!); var refreshedPrincipal = service.ValidateToken(refreshed!);
@@ -690,7 +690,7 @@ public class SecurityReviewRegressionTests2
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
}, "test")); }, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null); var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
var refreshedPrincipal = service.ValidateToken(refreshed!); var refreshedPrincipal = service.ValidateToken(refreshed!);
// Still 25 min idle after refresh — not reset to 0. // Still 25 min idle after refresh — not reset to 0.
@@ -715,7 +715,7 @@ public class SecurityReviewRegressionTests2
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
}, "test")); }, "test"));
var touched = service.RecordActivity(principal, new[] { "Admin" }, null); var touched = service.RecordActivity(principal, new[] { "Administrator" }, null);
Assert.NotNull(touched); Assert.NotNull(touched);
var touchedPrincipal = service.ValidateToken(touched!); var touchedPrincipal = service.ValidateToken(touched!);
@@ -834,7 +834,7 @@ public class SecurityReviewRegressionTests4
new Claim(JwtTokenService.LastActivityClaimType, idleActivity.ToString("o")) new Claim(JwtTokenService.LastActivityClaimType, idleActivity.ToString("o"))
}, "test")); }, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null); var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
Assert.Null(refreshed); Assert.Null(refreshed);
} }
@@ -853,7 +853,7 @@ public class SecurityReviewRegressionTests4
new Claim(JwtTokenService.LastActivityClaimType, recentActivity.ToString("o")) new Claim(JwtTokenService.LastActivityClaimType, recentActivity.ToString("o"))
}, "test")); }, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null); var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
Assert.NotNull(refreshed); Assert.NotNull(refreshed);
} }
@@ -870,7 +870,7 @@ public class SecurityReviewRegressionTests4
new Claim(JwtTokenService.UsernameClaimType, "user") new Claim(JwtTokenService.UsernameClaimType, "user")
}, "test")); }, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null); var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
Assert.Null(refreshed); Assert.Null(refreshed);
} }
@@ -979,33 +979,33 @@ public class Security012GroupLookupFailureTests
public class AuthorizationPolicyTests public class AuthorizationPolicyTests
{ {
[Fact] [Fact]
public async Task AdminPolicy_AdminRole_Succeeds() public async Task AdminPolicy_AdministratorRole_Succeeds()
{ {
var principal = CreatePrincipal(new[] { "Admin" }); var principal = CreatePrincipal(new[] { Roles.Administrator });
var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal); var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal);
Assert.True(result); Assert.True(result);
} }
[Fact] [Fact]
public async Task AdminPolicy_DesignRole_Fails() public async Task AdminPolicy_DesignerRole_Fails()
{ {
var principal = CreatePrincipal(new[] { "Design" }); var principal = CreatePrincipal(new[] { Roles.Designer });
var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal); var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal);
Assert.False(result); Assert.False(result);
} }
[Fact] [Fact]
public async Task DesignPolicy_DesignRole_Succeeds() public async Task DesignPolicy_DesignerRole_Succeeds()
{ {
var principal = CreatePrincipal(new[] { "Design" }); var principal = CreatePrincipal(new[] { Roles.Designer });
var result = await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal); var result = await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal);
Assert.True(result); Assert.True(result);
} }
[Fact] [Fact]
public async Task DeploymentPolicy_DeploymentRole_Succeeds() public async Task DeploymentPolicy_DeployerRole_Succeeds()
{ {
var principal = CreatePrincipal(new[] { "Deployment" }); var principal = CreatePrincipal(new[] { Roles.Deployer });
var result = await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal); var result = await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal);
Assert.True(result); Assert.True(result);
} }
@@ -1022,19 +1022,21 @@ public class AuthorizationPolicyTests
} }
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
// Audit Log #23 — OperationalAudit + AuditExport policies (M7-T15). // Audit Log #23 — OperationalAudit + AuditExport policies (M7-T15),
// Default mapping (see AuthorizationPolicies XML doc): // post Task 1.7 canonicalization + SoD collapse. Default mapping
// Admin → OperationalAudit + AuditExport // (see AuthorizationPolicies XML doc):
// Audit → OperationalAudit + AuditExport // Administrator → OperationalAudit + AuditExport
// AuditReadOnly → OperationalAudit only // Viewer → OperationalAudit only (former AuditReadOnly home)
// Design → neither // Designer → neither
// Deployment → neither // Deployer → neither
// The former distinct Audit/AuditReadOnly roles no longer exist:
// Audit → collapsed into Administrator
// AuditReadOnly → collapsed into Viewer
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
[Theory] [Theory]
[InlineData("Admin")] [InlineData("Administrator")]
[InlineData("Audit")] [InlineData("Viewer")]
[InlineData("AuditReadOnly")]
public async Task OperationalAuditPolicy_GrantedRoles_Succeed(string role) public async Task OperationalAuditPolicy_GrantedRoles_Succeed(string role)
{ {
var principal = CreatePrincipal(new[] { role }); var principal = CreatePrincipal(new[] { role });
@@ -1042,8 +1044,8 @@ public class AuthorizationPolicyTests
} }
[Theory] [Theory]
[InlineData("Design")] [InlineData("Designer")]
[InlineData("Deployment")] [InlineData("Deployer")]
public async Task OperationalAuditPolicy_UngrantedRoles_Fail(string role) public async Task OperationalAuditPolicy_UngrantedRoles_Fail(string role)
{ {
var principal = CreatePrincipal(new[] { role }); var principal = CreatePrincipal(new[] { role });
@@ -1051,8 +1053,7 @@ public class AuthorizationPolicyTests
} }
[Theory] [Theory]
[InlineData("Admin")] [InlineData("Administrator")]
[InlineData("Audit")]
public async Task AuditExportPolicy_GrantedRoles_Succeed(string role) public async Task AuditExportPolicy_GrantedRoles_Succeed(string role)
{ {
var principal = CreatePrincipal(new[] { role }); var principal = CreatePrincipal(new[] { role });
@@ -1060,18 +1061,38 @@ public class AuthorizationPolicyTests
} }
[Theory] [Theory]
[InlineData("AuditReadOnly")] [InlineData("Viewer")]
[InlineData("Design")] [InlineData("Designer")]
[InlineData("Deployment")] [InlineData("Deployer")]
public async Task AuditExportPolicy_UngrantedRoles_Fail(string role) public async Task AuditExportPolicy_UngrantedRoles_Fail(string role)
{ {
// AuditReadOnly is the load-bearing case: it grants OperationalAudit
// (read) but NOT AuditExport (bulk export) — the split that lets a
// triage operator drill in without exfiltrating the table.
var principal = CreatePrincipal(new[] { role }); var principal = CreatePrincipal(new[] { role });
Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal)); Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
} }
[Fact]
public async Task Viewer_ReadsAudit_ButCannotExport_PreservedHalfSoD()
{
// The load-bearing preserved-SoD case after the AuditReadOnly→Viewer
// collapse: a Viewer satisfies OperationalAudit (read the log + nav)
// but NOT AuditExport (bulk CSV exfiltration).
var principal = CreatePrincipal(new[] { Roles.Viewer });
Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
}
[Fact]
public async Task FormerAuditUser_NowAdministrator_GainsExportAndFullAdmin_DocumentedEscalation()
{
// The documented privilege escalation: the former Audit role collapsed
// into Administrator, so a former audit-only user now passes AuditExport
// AND RequireAdmin (the full admin surface).
var principal = CreatePrincipal(new[] { Roles.Administrator });
Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal));
Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
}
private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null) private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null)
{ {
var claims = new List<Claim>(); var claims = new List<Claim>();
@@ -1253,7 +1274,7 @@ public class CanonicalClaimVocabularyTests
var service = CreateService(); var service = CreateService();
var token = service.GenerateToken( var token = service.GenerateToken(
"Jane Roe", "janer", "Jane Roe", "janer",
new[] { Roles.Audit }, new[] { Roles.Administrator },
new[] { "7" }); new[] { "7" });
var principal = service.ValidateToken(token); var principal = service.ValidateToken(token);
@@ -1263,28 +1284,30 @@ public class CanonicalClaimVocabularyTests
// (MapInboundClaims=false / cleared outbound map guarantee this). // (MapInboundClaims=false / cleared outbound map guarantee this).
Assert.Equal("Jane Roe", principal!.FindFirst(ZbClaimTypes.DisplayName)?.Value); Assert.Equal("Jane Roe", principal!.FindFirst(ZbClaimTypes.DisplayName)?.Value);
Assert.Equal("janer", principal.FindFirst(ZbClaimTypes.Username)?.Value); Assert.Equal("janer", principal.FindFirst(ZbClaimTypes.Username)?.Value);
Assert.Equal(Roles.Audit, principal.FindFirst(ZbClaimTypes.Role)?.Value); Assert.Equal(Roles.Administrator, principal.FindFirst(ZbClaimTypes.Role)?.Value);
Assert.Equal("7", principal.FindFirst(ZbClaimTypes.ScopeId)?.Value); Assert.Equal("7", principal.FindFirst(ZbClaimTypes.ScopeId)?.Value);
} }
[Fact] [Fact]
public async Task MintedJwt_RoleClaim_SatisfiesOperationalAuditPolicy() public async Task MintedJwt_RoleClaim_SatisfiesOperationalAuditPolicy()
{ {
// The load-bearing round-trip: a JWT minted with RoleClaimType=Audit must satisfy // The load-bearing round-trip: a JWT minted with RoleClaimType=Administrator
// (post Task 1.7, the home of the former full-audit Audit role) must satisfy
// a RequireClaim(RoleClaimType, OperationalAuditRoles) policy after validation. // a RequireClaim(RoleClaimType, OperationalAuditRoles) policy after validation.
var service = CreateService(); var service = CreateService();
var token = service.GenerateToken("Jane Roe", "janer", new[] { Roles.Audit }, null); var token = service.GenerateToken("Jane Roe", "janer", new[] { Roles.Administrator }, null);
var principal = service.ValidateToken(token); var principal = service.ValidateToken(token);
Assert.NotNull(principal); Assert.NotNull(principal);
Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal!)); Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal!));
// Audit does NOT grant AuditExport via a different vocabulary by accident: // Administrator grants AuditExport too (it absorbed the former Audit role):
Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal!)); Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal!));
// AuditReadOnly is read-only — separate assertion that the role VALUE semantics // Viewer (post Task 1.7 home of the former AuditReadOnly role) is read-only —
// are untouched by the type migration. // separate assertion that the read-not-export half-SoD survives the type
// migration AND the role collapse.
var roPrincipal = service.ValidateToken( var roPrincipal = service.ValidateToken(
service.GenerateToken("RO", "ro", new[] { Roles.AuditReadOnly }, null)); service.GenerateToken("RO", "ro", new[] { Roles.Viewer }, null));
Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, roPrincipal!)); Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, roPrincipal!));
Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, roPrincipal!)); Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, roPrincipal!));
} }
@@ -1299,7 +1322,7 @@ public class CanonicalClaimVocabularyTests
new(ClaimTypes.Name, "janer"), new(ClaimTypes.Name, "janer"),
new(JwtTokenService.DisplayNameClaimType, "Jane Roe"), new(JwtTokenService.DisplayNameClaimType, "Jane Roe"),
new(JwtTokenService.UsernameClaimType, "janer"), new(JwtTokenService.UsernameClaimType, "janer"),
new(JwtTokenService.RoleClaimType, Roles.Admin), new(JwtTokenService.RoleClaimType, Roles.Administrator),
new(JwtTokenService.SiteIdClaimType, "3"), new(JwtTokenService.SiteIdClaimType, "3"),
}; };
var identity = new ClaimsIdentity( var identity = new ClaimsIdentity(
@@ -1315,7 +1338,7 @@ public class CanonicalClaimVocabularyTests
Assert.Equal("janer", principal.Identity?.Name); // ClaimTypes.Name resolves Identity.Name Assert.Equal("janer", principal.Identity?.Name); // ClaimTypes.Name resolves Identity.Name
// roleType wiring => IsInRole resolves against the canonical role claim. // roleType wiring => IsInRole resolves against the canonical role claim.
Assert.True(principal.IsInRole(Roles.Admin)); Assert.True(principal.IsInRole(Roles.Administrator));
// Admin holds every permission by convention. // Admin holds every permission by convention.
Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal)); Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal));