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:
@@ -6,6 +6,53 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [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
|
||||
|
||||
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>
|
||||
<select class="form-select form-select-sm" @bind="_formRole">
|
||||
<option value="">Select role...</option>
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="Design">Design</option>
|
||||
<option value="Deployment">Deployment</option>
|
||||
<option value="@Roles.Administrator">Administrator</option>
|
||||
<option value="@Roles.Designer">Designer</option>
|
||||
<option value="@Roles.Deployer">Deployer</option>
|
||||
<option value="@Roles.Viewer">Viewer</option>
|
||||
</select>
|
||||
<div class="form-text">Deployment role: configure site scope below after saving.</div>
|
||||
<div class="form-text">Deployer role: configure site scope below after saving.</div>
|
||||
</div>
|
||||
@if (_formError != null)
|
||||
{
|
||||
|
||||
@@ -36,11 +36,11 @@ public sealed class BindingTester : IBindingTester
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// CentralUI-side role guard — sites don't enforce envelope-level
|
||||
// roles, so the Design check must happen here before any cross-cluster
|
||||
// roles, so the Designer check must happen here before any cross-cluster
|
||||
// traffic. Use HasClaim against JwtTokenService.RoleClaimType (not
|
||||
// IsInRole, per c1e16cf).
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design"))
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
|
||||
{
|
||||
return new ReadTagValuesResult(
|
||||
Array.Empty<TagReadOutcome>(),
|
||||
|
||||
@@ -43,9 +43,9 @@ public sealed class BrowseService : IBrowseService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// CentralUI-side role guard — sites don't enforce envelope-level roles,
|
||||
// so the Design check must happen here before any cross-cluster traffic.
|
||||
// so the Designer check must happen here before any cross-cluster traffic.
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design"))
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
|
||||
{
|
||||
return new BrowseNodeResult(
|
||||
Array.Empty<BrowseNode>(),
|
||||
|
||||
+8
-5
@@ -25,12 +25,15 @@ public class LdapGroupMappingConfiguration : IEntityTypeConfiguration<LdapGroupM
|
||||
|
||||
builder.HasIndex(m => m.LdapGroupName).IsUnique();
|
||||
|
||||
// Seed default group mappings matching GLAuth test users
|
||||
// Seed default group mappings matching GLAuth test users.
|
||||
// Role VALUES are the canonical six (Task 1.7): Administrator/Designer/
|
||||
// Deployer. The LDAP group NAMES (SCADA-Admins etc.) are unchanged —
|
||||
// only the role each group maps to was canonicalized.
|
||||
builder.HasData(
|
||||
new LdapGroupMapping("SCADA-Admins", "Admin") { Id = 1 },
|
||||
new LdapGroupMapping("SCADA-Designers", "Design") { Id = 2 },
|
||||
new LdapGroupMapping("SCADA-Deploy-All", "Deployment") { Id = 3 },
|
||||
new LdapGroupMapping("SCADA-Deploy-SiteA", "Deployment") { Id = 4 });
|
||||
new LdapGroupMapping("SCADA-Admins", "Administrator") { Id = 1 },
|
||||
new LdapGroupMapping("SCADA-Designers", "Designer") { Id = 2 },
|
||||
new LdapGroupMapping("SCADA-Deploy-All", "Deployer") { Id = 3 },
|
||||
new LdapGroupMapping("SCADA-Deploy-SiteA", "Deployer") { Id = 4 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1740
File diff suppressed because it is too large
Load Diff
+133
@@ -0,0 +1,133 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Task 1.7 — canonicalizes ScadaBridge role VALUES onto the canonical six
|
||||
/// (only Administrator/Designer/Deployer/Viewer are used here) and collapses
|
||||
/// the former audit roles:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Admin → Administrator</description></item>
|
||||
/// <item><description>Design → Designer</description></item>
|
||||
/// <item><description>Deployment → Deployer</description></item>
|
||||
/// <item><description>Audit → Administrator (COLLAPSE — accepted SoD loss /
|
||||
/// privilege escalation)</description></item>
|
||||
/// <item><description>AuditReadOnly → Viewer (COLLAPSE — keeps audit-read,
|
||||
/// never had export)</description></item>
|
||||
/// </list>
|
||||
/// The <c>UpdateData</c> calls below canonicalize the four seeded rows
|
||||
/// (Id 1-4). The raw <c>Sql</c> catch-alls then canonicalize ANY
|
||||
/// operator-added (non-seed) rows whose <c>Role</c> still holds a legacy
|
||||
/// value — <c>LdapGroupMappings.Role</c> is free-text nvarchar, so operators
|
||||
/// may have created rows with Admin/Design/Deployment/Audit/AuditReadOnly.
|
||||
/// The catch-alls are value-keyed (WHERE Role IN (...)), so they are
|
||||
/// idempotent and safe whether or not the seed <c>UpdateData</c> already ran
|
||||
/// (after the seed update the seed rows no longer match a legacy value, so
|
||||
/// they are simply skipped).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// LOSSY DOWN: the Audit/AuditReadOnly → Administrator/Viewer collapse is not
|
||||
/// perfectly reversible. Down is a best-effort that maps Administrator→Admin
|
||||
/// and Viewer→AuditReadOnly. It CANNOT recover the original Audit vs Admin
|
||||
/// distinction (both became Administrator) nor distinguish a genuine Viewer
|
||||
/// from a former AuditReadOnly (both became Viewer). The four seeded rows are
|
||||
/// restored exactly by the Id-keyed <c>UpdateData</c> calls; operator rows
|
||||
/// are reverted by the value-keyed catch-alls under this caveat.
|
||||
/// </remarks>
|
||||
public partial class CanonicalizeRoles : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 1) Canonicalize the four seeded rows by Id (EF-scaffolded).
|
||||
migrationBuilder.UpdateData(
|
||||
table: "LdapGroupMappings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "Role",
|
||||
value: "Administrator");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "LdapGroupMappings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "Role",
|
||||
value: "Designer");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "LdapGroupMappings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "Role",
|
||||
value: "Deployer");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "LdapGroupMappings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 4,
|
||||
column: "Role",
|
||||
value: "Deployer");
|
||||
|
||||
// 2) Catch-all for operator-added (non-seed) rows. Value-keyed and
|
||||
// idempotent. Audit collapses INTO Administrator; AuditReadOnly
|
||||
// collapses INTO Viewer.
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE [LdapGroupMappings] SET [Role] = N'Administrator' WHERE [Role] IN (N'Admin', N'Audit');");
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE [LdapGroupMappings] SET [Role] = N'Designer' WHERE [Role] = N'Design';");
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE [LdapGroupMappings] SET [Role] = N'Deployer' WHERE [Role] = N'Deployment';");
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE [LdapGroupMappings] SET [Role] = N'Viewer' WHERE [Role] = N'AuditReadOnly';");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Restore the four seeded rows exactly (Id-keyed). Down is lossy for
|
||||
// the collapsed audit roles — see the class <remarks>.
|
||||
migrationBuilder.UpdateData(
|
||||
table: "LdapGroupMappings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "Role",
|
||||
value: "Admin");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "LdapGroupMappings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "Role",
|
||||
value: "Design");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "LdapGroupMappings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "Role",
|
||||
value: "Deployment");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "LdapGroupMappings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 4,
|
||||
column: "Role",
|
||||
value: "Deployment");
|
||||
|
||||
// Best-effort reverse for operator-added (non-seed) rows. LOSSY:
|
||||
// Administrator→Admin and Viewer→AuditReadOnly cannot recover the
|
||||
// original Audit/Admin or Viewer/AuditReadOnly distinction (the
|
||||
// forward collapse merged those). Value-keyed and idempotent.
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE [LdapGroupMappings] SET [Role] = N'Admin' WHERE [Role] = N'Administrator';");
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE [LdapGroupMappings] SET [Role] = N'Design' WHERE [Role] = N'Designer';");
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE [LdapGroupMappings] SET [Role] = N'Deployment' WHERE [Role] = N'Deployer';");
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE [LdapGroupMappings] SET [Role] = N'AuditReadOnly' WHERE [Role] = N'Viewer';");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1045,25 +1045,25 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
LdapGroupName = "SCADA-Admins",
|
||||
Role = "Admin"
|
||||
Role = "Administrator"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
LdapGroupName = "SCADA-Designers",
|
||||
Role = "Design"
|
||||
Role = "Designer"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
LdapGroupName = "SCADA-Deploy-All",
|
||||
Role = "Deployment"
|
||||
Role = "Deployer"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
LdapGroupName = "SCADA-Deploy-SiteA",
|
||||
Role = "Deployment"
|
||||
Role = "Deployer"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -402,10 +402,11 @@ public static class AuditEndpoints
|
||||
/// </returns>
|
||||
public static AuditLogQueryFilter? ApplySiteScope(AuditLogQueryFilter filter, AuthenticatedUser user)
|
||||
{
|
||||
// Empty PermittedSiteIds is the system-wide signal (Admin, system-wide
|
||||
// Deployment). System-wide audit roles also fall here — the design treats
|
||||
// Audit/AuditReadOnly as non-site-scoped unless an operator attaches scope
|
||||
// rules to the LDAP mapping; if they do, this helper enforces them.
|
||||
// Empty PermittedSiteIds is the system-wide signal (Administrator,
|
||||
// system-wide Deployer). System-wide audit roles also fall here — the
|
||||
// design treats Administrator/Viewer (the post-Task-1.7 audit roles) as
|
||||
// non-site-scoped unless an operator attaches scope rules to the LDAP
|
||||
// mapping; if they do, this helper enforces them.
|
||||
if (user.PermittedSiteIds.Length == 0)
|
||||
{
|
||||
return filter;
|
||||
|
||||
@@ -26,8 +26,8 @@ public class DebugStreamHub : Hub
|
||||
/// Pure site-scope authorization check for a debug-stream subscription.
|
||||
/// Returns true when the caller may subscribe to a debug stream for an instance
|
||||
/// belonging to <paramref name="instanceSiteId"/>.
|
||||
/// Admin role, or an empty <paramref name="permittedSiteIds"/> (system-wide
|
||||
/// Deployment), grants access to any site; otherwise the instance's site must be
|
||||
/// Administrator role, or an empty <paramref name="permittedSiteIds"/> (system-wide
|
||||
/// Deployer), grants access to any site; otherwise the instance's site must be
|
||||
/// in the permitted set.
|
||||
/// </summary>
|
||||
/// <param name="roles">Roles held by the connected user.</param>
|
||||
@@ -38,7 +38,7 @@ public class DebugStreamHub : Hub
|
||||
IReadOnlyCollection<string> permittedSiteIds,
|
||||
int instanceSiteId)
|
||||
{
|
||||
if (roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) return true;
|
||||
if (roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return true;
|
||||
if (permittedSiteIds.Count == 0) return true; // system-wide deployment
|
||||
return permittedSiteIds.Contains(instanceSiteId.ToString());
|
||||
}
|
||||
@@ -109,13 +109,13 @@ public class DebugStreamHub : Hub
|
||||
return;
|
||||
}
|
||||
|
||||
// Role check — Deployment role required
|
||||
// Role check — Deployer role required
|
||||
var roleMapper = httpContext.RequestServices.GetRequiredService<RoleMapper>();
|
||||
var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, Context.ConnectionAborted);
|
||||
|
||||
if (!mappingResult.Roles.Contains("Deployment"))
|
||||
if (!mappingResult.Roles.Contains(Roles.Deployer))
|
||||
{
|
||||
_logger.LogWarning("DebugStreamHub connection rejected: {Username} lacks Deployment role", username);
|
||||
_logger.LogWarning("DebugStreamHub connection rejected: {Username} lacks Deployer role", username);
|
||||
Context.Abort();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ public class ManagementActor : ReceiveActor
|
||||
|
||||
private static string? GetRequiredRole(object command) => command switch
|
||||
{
|
||||
// Admin operations
|
||||
// Administrator operations
|
||||
CreateSiteCommand or UpdateSiteCommand or DeleteSiteCommand
|
||||
or ListRoleMappingsCommand or CreateRoleMappingCommand
|
||||
or UpdateRoleMappingCommand or DeleteRoleMappingCommand
|
||||
@@ -167,9 +167,9 @@ public class ManagementActor : ReceiveActor
|
||||
// should use the REST endpoint; this command is retained for
|
||||
// backward compatibility with the CentralUI Configuration Audit
|
||||
// Log page (Management-018).
|
||||
or QueryAuditLogCommand => "Admin",
|
||||
or QueryAuditLogCommand => Roles.Administrator,
|
||||
|
||||
// Design operations
|
||||
// Designer operations
|
||||
CreateAreaCommand or DeleteAreaCommand
|
||||
or CreateTemplateCommand or UpdateTemplateCommand or DeleteTemplateCommand
|
||||
or ValidateTemplateCommand
|
||||
@@ -194,14 +194,15 @@ public class ManagementActor : ReceiveActor
|
||||
or CreateTemplateFolderCommand or RenameTemplateFolderCommand
|
||||
or MoveTemplateFolderCommand or DeleteTemplateFolderCommand
|
||||
or MoveTemplateToFolderCommand
|
||||
or ExportBundleCommand => "Design",
|
||||
or ExportBundleCommand => Roles.Designer,
|
||||
|
||||
// Transport import operations (mirror the Central UI gating: Admin
|
||||
// for inbound bundle handling because they mutate cross-cutting
|
||||
// configuration; Export stays Design because it only reads).
|
||||
PreviewBundleCommand or ImportBundleCommand => "Admin",
|
||||
// Transport import operations (mirror the Central UI gating:
|
||||
// Administrator for inbound bundle handling because they mutate
|
||||
// cross-cutting configuration; Export stays Designer because it only
|
||||
// reads).
|
||||
PreviewBundleCommand or ImportBundleCommand => Roles.Administrator,
|
||||
|
||||
// Deployment operations
|
||||
// Deployer operations
|
||||
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand
|
||||
or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand
|
||||
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
|
||||
@@ -211,7 +212,7 @@ public class ManagementActor : ReceiveActor
|
||||
or MgmtDeployArtifactsCommand
|
||||
or QueryDeploymentsCommand
|
||||
or RetryParkedMessageCommand or DiscardParkedMessageCommand
|
||||
or DebugSnapshotCommand => "Deployment",
|
||||
or DebugSnapshotCommand => Roles.Deployer,
|
||||
|
||||
// Read-only queries -- any authenticated user
|
||||
_ => null
|
||||
@@ -385,15 +386,15 @@ public class ManagementActor : ReceiveActor
|
||||
// ========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Throws SiteScopeViolationException if the user has site-scoped Deployment
|
||||
/// Throws SiteScopeViolationException if the user has site-scoped Deployer
|
||||
/// and the target site is not in their permitted sites.
|
||||
/// Users with Admin or Design roles, or system-wide Deployment, are not restricted.
|
||||
/// Users with the Administrator role, or system-wide Deployer, are not restricted.
|
||||
/// </summary>
|
||||
private static void EnforceSiteScope(AuthenticatedUser user, int? targetSiteId)
|
||||
{
|
||||
if (targetSiteId == null) return;
|
||||
if (user.PermittedSiteIds.Length == 0) return; // system-wide access
|
||||
if (user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) return;
|
||||
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return;
|
||||
|
||||
if (!user.PermittedSiteIds.Contains(targetSiteId.Value.ToString()))
|
||||
{
|
||||
@@ -409,7 +410,7 @@ public class ManagementActor : ReceiveActor
|
||||
private static async Task EnforceSiteScopeForInstance(IServiceProvider sp, AuthenticatedUser user, int instanceId)
|
||||
{
|
||||
if (user.PermittedSiteIds.Length == 0) return;
|
||||
if (user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) return;
|
||||
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return;
|
||||
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
var instance = await repo.GetInstanceByIdAsync(instanceId);
|
||||
@@ -424,7 +425,7 @@ public class ManagementActor : ReceiveActor
|
||||
private static async Task EnforceSiteScopeForIdentifier(IServiceProvider sp, AuthenticatedUser user, string siteIdentifier)
|
||||
{
|
||||
if (user.PermittedSiteIds.Length == 0) return;
|
||||
if (user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) return;
|
||||
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return;
|
||||
|
||||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||||
var site = await repo.GetSiteByIdentifierAsync(siteIdentifier);
|
||||
@@ -617,7 +618,7 @@ public class ManagementActor : ReceiveActor
|
||||
var repo = sp.GetRequiredService<ICentralUiRepository>();
|
||||
var instances = await repo.GetInstancesFilteredAsync(cmd.SiteId, cmd.TemplateId, cmd.SearchTerm);
|
||||
// Filter by permitted sites for site-scoped users
|
||||
if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase))
|
||||
if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
|
||||
instances = instances.Where(i => permittedIds.Contains(i.SiteId.ToString())).ToList();
|
||||
@@ -864,7 +865,7 @@ public class ManagementActor : ReceiveActor
|
||||
{
|
||||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||||
var sites = await repo.GetAllSitesAsync();
|
||||
if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase))
|
||||
if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
|
||||
sites = sites.Where(s => permittedIds.Contains(s.Id.ToString())).ToList();
|
||||
@@ -1372,7 +1373,7 @@ public class ManagementActor : ReceiveActor
|
||||
// SiteId, so resolve each record's instance to its site and filter
|
||||
// (mirrors the HandleListInstances / HandleListSites filter pattern).
|
||||
var records = await repo.GetAllDeploymentRecordsAsync();
|
||||
if (user.PermittedSiteIds.Length == 0 || user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase))
|
||||
if (user.PermittedSiteIds.Length == 0 || user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
|
||||
return records;
|
||||
|
||||
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
|
||||
|
||||
@@ -18,44 +18,52 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Default role → permission mapping (#23 M7-T15 / Bundle G):
|
||||
/// Default role → permission mapping (#23 M7-T15 / Bundle G), post Task 1.7
|
||||
/// canonicalization + SoD collapse:
|
||||
/// <list type="table">
|
||||
/// <listheader>
|
||||
/// <term>Role</term>
|
||||
/// <description>Policies granted</description>
|
||||
/// </listheader>
|
||||
/// <item>
|
||||
/// <term><c>Admin</c></term>
|
||||
/// <term><c>Administrator</c></term>
|
||||
/// <description><see cref="RequireAdmin"/>,
|
||||
/// <see cref="OperationalAudit"/>, <see cref="AuditExport"/> — admins hold
|
||||
/// every permission by convention so an Admin-only user never loses
|
||||
/// every permission by convention so an Administrator-only user never loses
|
||||
/// access to a new surface.</description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term><c>Design</c></term>
|
||||
/// <term><c>Designer</c></term>
|
||||
/// <description><see cref="RequireDesign"/></description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term><c>Deployment</c></term>
|
||||
/// <term><c>Deployer</c></term>
|
||||
/// <description><see cref="RequireDeployment"/></description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term><c>Audit</c></term>
|
||||
/// <description><see cref="OperationalAudit"/>,
|
||||
/// <see cref="AuditExport"/> — the full audit surface (read + bulk
|
||||
/// export) per <c>Component-AuditLog.md</c> §"Authorization".</description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term><c>AuditReadOnly</c></term>
|
||||
/// <description><see cref="OperationalAudit"/> only — operators who
|
||||
/// should see the Audit Log + drill in to incidents but not pull bulk
|
||||
/// CSV exports. Use this when delegating triage without granting
|
||||
/// forensic-export capability.</description>
|
||||
/// <term><c>Viewer</c></term>
|
||||
/// <description><see cref="OperationalAudit"/> only — read access to the
|
||||
/// Audit Log + nav, but NOT <see cref="AuditExport"/>. This preserves the
|
||||
/// half-SoD that the legacy <c>AuditReadOnly</c> role provided (read-not-
|
||||
/// export) after <c>AuditReadOnly</c> was collapsed into
|
||||
/// <c>Viewer</c>.</description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// SoD collapse (Task 1.7): the legacy distinct audit roles were removed. The
|
||||
/// former <c>Audit</c> role (full audit surface = read + bulk export) was
|
||||
/// collapsed into <c>Administrator</c> — a deliberate, accepted privilege
|
||||
/// escalation (former audit-only users gain the entire admin surface: create
|
||||
/// sites, manage LDAP mappings/API keys, import bundles). The former
|
||||
/// <c>AuditReadOnly</c> role (read-only audit) was collapsed into
|
||||
/// <c>Viewer</c>, which keeps audit-read but correctly LACKS export. The net
|
||||
/// effect on the audit policies: <see cref="OperationalAudit"/> is granted to
|
||||
/// {<c>Administrator</c>, <c>Viewer</c>} and <see cref="AuditExport"/> only to
|
||||
/// {<c>Administrator</c>}.
|
||||
/// </para>
|
||||
/// LDAP group → role mapping is configured via the central UI Admin → LDAP
|
||||
/// Mappings page (rows in <c>LdapGroupMappings</c>); the same code path
|
||||
/// reads them whether the role is one of the four built-ins above or any
|
||||
/// reads them whether the role is one of the built-ins above or any
|
||||
/// future addition. Adding a role here means adding the LDAP mapping row in
|
||||
/// the deployment; no schema migration is needed.
|
||||
/// </para>
|
||||
@@ -69,16 +77,16 @@ public static class AuthorizationPolicies
|
||||
/// <summary>
|
||||
/// Read access to the Audit Log #23 surface (Audit Log page,
|
||||
/// Configuration Audit Log page, Audit nav group). Granted to the
|
||||
/// <c>Audit</c> role, the <c>AuditReadOnly</c> role, and the
|
||||
/// <c>Admin</c> role.
|
||||
/// <c>Administrator</c> role and the <c>Viewer</c> role (the latter being
|
||||
/// the post-Task-1.7 home of the former <c>AuditReadOnly</c> role).
|
||||
/// </summary>
|
||||
public const string OperationalAudit = "OperationalAudit";
|
||||
|
||||
/// <summary>
|
||||
/// Permission to pull a bulk CSV export of the Audit Log. Separate from
|
||||
/// <see cref="OperationalAudit"/> so a triage operator can read the
|
||||
/// <see cref="OperationalAudit"/> so a <c>Viewer</c> can read the
|
||||
/// table without being able to exfiltrate it in bulk. Granted to the
|
||||
/// <c>Audit</c> role and the <c>Admin</c> role.
|
||||
/// <c>Administrator</c> role only.
|
||||
/// </summary>
|
||||
public const string AuditExport = "AuditExport";
|
||||
|
||||
@@ -91,20 +99,23 @@ public static class AuthorizationPolicies
|
||||
/// <c>/api/audit/*</c> routes with a manual Basic-Auth + LDAP role check
|
||||
/// rather than the ASP.NET authorization-policy pipeline — can reuse the
|
||||
/// exact same role set the <see cref="OperationalAudit"/> policy enforces.
|
||||
/// Task 1.7: {<c>Administrator</c>, <c>Viewer</c>} (was {Admin, Audit,
|
||||
/// AuditReadOnly} — the audit roles collapsed into Administrator/Viewer).
|
||||
/// </remarks>
|
||||
public static readonly string[] OperationalAuditRoles = { Roles.Admin, Roles.Audit, Roles.AuditReadOnly };
|
||||
public static readonly string[] OperationalAuditRoles = { Roles.Administrator, Roles.Viewer };
|
||||
|
||||
/// <summary>
|
||||
/// Roles that satisfy <see cref="AuditExport"/>. A strict subset of
|
||||
/// <see cref="OperationalAuditRoles"/> — read access does NOT imply
|
||||
/// export permission.
|
||||
/// export permission, so <c>Viewer</c> can read but not export.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Public for the same reason as <see cref="OperationalAuditRoles"/> —
|
||||
/// the ManagementService <c>/api/audit/export</c> route checks roles
|
||||
/// against this set directly.
|
||||
/// against this set directly. Task 1.7: {<c>Administrator</c>} (was
|
||||
/// {Admin, Audit}).
|
||||
/// </remarks>
|
||||
public static readonly string[] AuditExportRoles = { Roles.Admin, Roles.Audit };
|
||||
public static readonly string[] AuditExportRoles = { Roles.Administrator };
|
||||
|
||||
/// <summary>
|
||||
/// Registers the ScadaBridge authorization policies (Admin, Design, Deployment, OperationalAudit, AuditExport).
|
||||
@@ -115,21 +126,21 @@ public static class AuthorizationPolicies
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(RequireAdmin, policy =>
|
||||
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Admin));
|
||||
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Administrator));
|
||||
|
||||
options.AddPolicy(RequireDesign, policy =>
|
||||
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Design));
|
||||
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Designer));
|
||||
|
||||
options.AddPolicy(RequireDeployment, policy =>
|
||||
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Deployment));
|
||||
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Deployer));
|
||||
|
||||
// Multi-role permission policies — the policy succeeds when the
|
||||
// principal holds ANY of the mapped roles. RequireClaim with
|
||||
// multiple allowed values is the right primitive: it checks
|
||||
// whether *any* role claim's value is in the allowed set, so a
|
||||
// user with role=Admin (and nothing else) satisfies the
|
||||
// OperationalAudit policy without needing a separate Audit
|
||||
// role claim.
|
||||
// user with role=Administrator (and nothing else) satisfies the
|
||||
// OperationalAudit policy, and a user with role=Viewer satisfies
|
||||
// OperationalAudit but not AuditExport.
|
||||
options.AddPolicy(OperationalAudit, policy =>
|
||||
policy.RequireClaim(JwtTokenService.RoleClaimType, OperationalAuditRoles));
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ public class RoleMapper
|
||||
|
||||
matchedRoles.Add(mapping.Role);
|
||||
|
||||
if (mapping.Role.Equals(Roles.Deployment, StringComparison.OrdinalIgnoreCase))
|
||||
if (mapping.Role.Equals(Roles.Deployer, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
hasDeploymentRole = true;
|
||||
|
||||
|
||||
@@ -5,18 +5,37 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
/// Security module and downstream authorization checks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Role names appear in three independent contexts: <see cref="RoleMapper"/>
|
||||
/// (LDAP-group → role resolution), <see cref="AuthorizationPolicies"/>
|
||||
/// (policy <c>RequireClaim</c> values + the audit role arrays), and at LDAP
|
||||
/// mapping rows configured by an operator. Holding the literals here means a
|
||||
/// rename either succeeds everywhere or fails to compile, eliminating the
|
||||
/// "string drift" class that Security-018 documented.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Task 1.7 canonicalization (auth normalization): role VALUES were
|
||||
/// standardized onto the canonical six (<c>Viewer/Operator/Engineer/Designer/
|
||||
/// Deployer/Administrator</c>; only four are used by ScadaBridge). The legacy
|
||||
/// ScadaBridge role names were renamed/collapsed as follows:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>Admin</c> → <c>Administrator</c></description></item>
|
||||
/// <item><description><c>Design</c> → <c>Designer</c></description></item>
|
||||
/// <item><description><c>Deployment</c> → <c>Deployer</c></description></item>
|
||||
/// <item><description><c>Audit</c> → <c>Administrator</c> (COLLAPSE — accepted
|
||||
/// separation-of-duties loss; a former audit-only user gains the full admin
|
||||
/// surface)</description></item>
|
||||
/// <item><description><c>AuditReadOnly</c> → <c>Viewer</c> (COLLAPSE — keeps
|
||||
/// audit-read + nav, loses bulk export, which it never had)</description></item>
|
||||
/// </list>
|
||||
/// <c>Operator</c> and <c>Engineer</c> exist in the canonical vocabulary but are
|
||||
/// unused by ScadaBridge, so they are intentionally not declared here.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class Roles
|
||||
{
|
||||
public const string Admin = "Admin";
|
||||
public const string Design = "Design";
|
||||
public const string Deployment = "Deployment";
|
||||
public const string Audit = "Audit";
|
||||
public const string AuditReadOnly = "AuditReadOnly";
|
||||
public const string Administrator = "Administrator";
|
||||
public const string Designer = "Designer";
|
||||
public const string Deployer = "Deployer";
|
||||
public const string Viewer = "Viewer";
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ public class ApiKeyFormAuditDrillinTests : BunitContext
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "admin"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Admin"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Administrator"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
|
||||
@@ -27,7 +27,7 @@ public class ApiKeysListPageTests : BunitContext
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "admin"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Admin"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Administrator"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
|
||||
@@ -36,7 +36,7 @@ public class SiteFormAuditDrillinTests : BunitContext
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "admin"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Admin"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Administrator"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
|
||||
@@ -81,7 +81,7 @@ public class AuditExportEndpointsTests
|
||||
// Use the real production policy wiring so the endpoint's
|
||||
// updated AuditExport gate (#23 M7-T15 Bundle G) is what
|
||||
// the tests exercise. The fake principal carries the
|
||||
// "Admin" role, which AuditExportRoles permits.
|
||||
// "Administrator" role, which AuditExportRoles permits.
|
||||
services.AddScadaBridgeAuthorization();
|
||||
services.AddSingleton(repo);
|
||||
services.AddScoped<IAuditLogExportService, AuditLogExportService>();
|
||||
@@ -288,7 +288,7 @@ public class AuditExportEndpointsTests
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.Name, "test-admin"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Admin"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Administrator"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
@@ -37,7 +37,7 @@ public class SiteScopeServiceTests
|
||||
[Fact]
|
||||
public async Task DeploymentUserWithNoSiteClaims_IsSystemWide()
|
||||
{
|
||||
var svc = ForUser(Role("Deployment"));
|
||||
var svc = ForUser(Role("Deployer"));
|
||||
|
||||
Assert.True(await svc.IsSystemWideAsync());
|
||||
Assert.Empty(await svc.PermittedSiteIdsAsync());
|
||||
@@ -46,7 +46,7 @@ public class SiteScopeServiceTests
|
||||
[Fact]
|
||||
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));
|
||||
|
||||
@@ -57,7 +57,7 @@ public class SiteScopeServiceTests
|
||||
public async Task ScopedUser_FilterSites_ReturnsOnlyPermittedSites()
|
||||
{
|
||||
// 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));
|
||||
|
||||
@@ -67,7 +67,7 @@ public class SiteScopeServiceTests
|
||||
[Fact]
|
||||
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.False(await svc.IsSiteAllowedAsync(6));
|
||||
@@ -76,7 +76,7 @@ public class SiteScopeServiceTests
|
||||
[Fact]
|
||||
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.Equal(new[] { 7, 9 }, (await svc.PermittedSiteIdsAsync()).OrderBy(x => x));
|
||||
@@ -85,7 +85,7 @@ public class SiteScopeServiceTests
|
||||
[Fact]
|
||||
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(999));
|
||||
|
||||
@@ -30,7 +30,7 @@ public class DataConnectionFormTests : BunitContext
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Admin")
|
||||
new Claim(JwtTokenService.RoleClaimType, "Administrator")
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
|
||||
@@ -37,7 +37,7 @@ public class DataConnectionsPageTests : BunitContext
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Admin")
|
||||
new Claim(JwtTokenService.RoleClaimType, "Administrator")
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "deployer"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployment"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployer"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
var authProvider = new TestAuthStateProvider(user);
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Design"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Designer"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
|
||||
@@ -76,7 +76,7 @@ public class NavMenuTests : BunitContext
|
||||
[Fact]
|
||||
public void Sections_AreCollapsedByDefault()
|
||||
{
|
||||
var cut = RenderWithRoles("Admin", "Design", "Deployment");
|
||||
var cut = RenderWithRoles("Administrator", "Designer", "Deployer");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -92,7 +92,7 @@ public class NavMenuTests : BunitContext
|
||||
[Fact]
|
||||
public void TogglingSection_RevealsItsItems()
|
||||
{
|
||||
var cut = RenderWithRoles("Deployment");
|
||||
var cut = RenderWithRoles("Deployer");
|
||||
Assert.DoesNotContain("/deployment/topology", cut.Markup);
|
||||
|
||||
ExpandSection(cut, "Deployment");
|
||||
@@ -105,7 +105,7 @@ public class NavMenuTests : BunitContext
|
||||
[Fact]
|
||||
public void TogglingSection_PersistsStateToCookie()
|
||||
{
|
||||
var cut = RenderWithRoles("Deployment");
|
||||
var cut = RenderWithRoles("Deployer");
|
||||
|
||||
ExpandSection(cut, "Deployment");
|
||||
|
||||
@@ -117,7 +117,7 @@ public class NavMenuTests : BunitContext
|
||||
[Fact]
|
||||
public void NotificationsSection_ShowsAllItems_ForMultiRoleUser()
|
||||
{
|
||||
var cut = RenderWithRoles("Admin", "Design", "Deployment");
|
||||
var cut = RenderWithRoles("Administrator", "Designer", "Deployer");
|
||||
ExpandSection(cut, "Notifications");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
@@ -133,7 +133,7 @@ public class NavMenuTests : BunitContext
|
||||
[Fact]
|
||||
public void NotificationsSection_AdminOnlyUser_SeesOnlySmtp()
|
||||
{
|
||||
var cut = RenderWithRoles("Admin");
|
||||
var cut = RenderWithRoles("Administrator");
|
||||
ExpandSection(cut, "Notifications");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
@@ -148,7 +148,7 @@ public class NavMenuTests : BunitContext
|
||||
[Fact]
|
||||
public void OldRoutes_AreNoLongerLinked()
|
||||
{
|
||||
var cut = RenderWithRoles("Admin", "Design", "Deployment");
|
||||
var cut = RenderWithRoles("Administrator", "Designer", "Deployer");
|
||||
|
||||
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
|
||||
/// the streaming export endpoint.</item>
|
||||
/// </list>
|
||||
/// Both policies are satisfied by the <c>Audit</c> role and (defence in depth)
|
||||
/// the <c>Admin</c> role — admins see everything by convention in this
|
||||
/// codebase. The tests pin both the page-level + endpoint-level enforcement,
|
||||
/// and the Export-button visibility split.
|
||||
/// Post Task 1.7: <c>AuditExport</c> is satisfied only by the
|
||||
/// <c>Administrator</c> role (which absorbed the former <c>Audit</c> role);
|
||||
/// <c>OperationalAudit</c> is additionally satisfied by the <c>Viewer</c> role
|
||||
/// (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>
|
||||
/// </summary>
|
||||
public class AuditLogPagePermissionTests : BunitContext
|
||||
@@ -106,15 +108,15 @@ public class AuditLogPagePermissionTests : BunitContext
|
||||
[Fact]
|
||||
public async Task WithoutOperationalAudit_PolicyDenies()
|
||||
{
|
||||
// A Design-only user (no Audit, no Admin) must NOT satisfy the
|
||||
// OperationalAudit policy.
|
||||
// A Designer-only user (not Administrator, not Viewer) must NOT satisfy
|
||||
// the OperationalAudit policy.
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddScadaBridgeAuthorization();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var authService = provider.GetRequiredService<IAuthorizationService>();
|
||||
|
||||
var principal = BuildPrincipal("Design");
|
||||
var principal = BuildPrincipal("Designer");
|
||||
var result = await authService.AuthorizeAsync(
|
||||
principal, null, AuthorizationPolicies.OperationalAudit);
|
||||
|
||||
@@ -156,11 +158,12 @@ public class AuditLogPagePermissionTests : BunitContext
|
||||
[Fact]
|
||||
public void WithOperationalAudit_NoAuditExport_PageRenders_ExportButtonHidden()
|
||||
{
|
||||
// The "Audit" role grants OperationalAudit + AuditExport in the
|
||||
// default mapping, so we test the split by handing the user ONLY
|
||||
// an extra-narrow role that we map ONLY to OperationalAudit: a
|
||||
// fresh "AuditReadOnly" role (see AuthorizationPolicies).
|
||||
var cut = RenderAuditLogPage("AuditReadOnly");
|
||||
// The "Administrator" role grants OperationalAudit + AuditExport, so we
|
||||
// test the split by handing the user ONLY the "Viewer" role, which maps
|
||||
// to OperationalAudit (read) but NOT AuditExport — the preserved
|
||||
// half-SoD after the Task 1.7 AuditReadOnly→Viewer collapse
|
||||
// (see AuthorizationPolicies).
|
||||
var cut = RenderAuditLogPage("Viewer");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -174,7 +177,7 @@ public class AuditLogPagePermissionTests : BunitContext
|
||||
[Fact]
|
||||
public void WithOperationalAudit_AndAuditExport_PageRenders_ExportButtonVisible()
|
||||
{
|
||||
var cut = RenderAuditLogPage("Audit");
|
||||
var cut = RenderAuditLogPage("Administrator");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -186,9 +189,9 @@ public class AuditLogPagePermissionTests : BunitContext
|
||||
[Fact]
|
||||
public void AdminUser_SeesPage_AndExportButton()
|
||||
{
|
||||
// Admin holds every permission by convention — both policies must
|
||||
// succeed for a plain Admin user.
|
||||
var cut = RenderAuditLogPage("Admin");
|
||||
// Administrator holds every permission by convention — both policies must
|
||||
// succeed for a plain Administrator user.
|
||||
var cut = RenderAuditLogPage("Administrator");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -204,9 +207,9 @@ public class AuditLogPagePermissionTests : BunitContext
|
||||
[Fact]
|
||||
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.
|
||||
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Design" });
|
||||
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Designer" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.GetAsync("/api/centralui/audit/export");
|
||||
@@ -217,7 +220,7 @@ public class AuditLogPagePermissionTests : BunitContext
|
||||
[Fact]
|
||||
public async Task AuditExportEndpoint_WithAuditExport_Returns200()
|
||||
{
|
||||
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Audit" });
|
||||
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Administrator" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.GetAsync("/api/centralui/audit/export");
|
||||
@@ -228,8 +231,9 @@ public class AuditLogPagePermissionTests : BunitContext
|
||||
[Fact]
|
||||
public async Task AuditExportEndpoint_AdminAlone_Returns200()
|
||||
{
|
||||
// Admin alone (no Audit role) must still pass — defence in depth.
|
||||
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Admin" });
|
||||
// Administrator alone must pass — it absorbed the former Audit role and
|
||||
// holds AuditExport by convention (defence in depth).
|
||||
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Administrator" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.GetAsync("/api/centralui/audit/export");
|
||||
@@ -238,12 +242,12 @@ public class AuditLogPagePermissionTests : BunitContext
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuditExportEndpoint_AuditReadOnly_Returns403()
|
||||
public async Task AuditExportEndpoint_Viewer_Returns403()
|
||||
{
|
||||
// AuditReadOnly grants OperationalAudit but NOT AuditExport, so the
|
||||
// endpoint must refuse — the page is readable but the bulk export
|
||||
// path is gated separately.
|
||||
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "AuditReadOnly" });
|
||||
// Viewer (former AuditReadOnly) grants OperationalAudit but NOT
|
||||
// AuditExport, so the endpoint must refuse — the page is readable but
|
||||
// the bulk export path is gated separately (preserved half-SoD).
|
||||
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Viewer" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.GetAsync("/api/centralui/audit/export");
|
||||
|
||||
@@ -118,7 +118,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
||||
[Fact]
|
||||
public void AuditLogPage_Renders_PageHeading()
|
||||
{
|
||||
var cut = RenderAuditLogPage("Admin");
|
||||
var cut = RenderAuditLogPage("Administrator");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -132,7 +132,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
||||
[Fact]
|
||||
public void NavMenu_Contains_AuditGroup_With_AuditLog_Link()
|
||||
{
|
||||
var cut = RenderNavMenu("Admin", "Design", "Deployment");
|
||||
var cut = RenderNavMenu("Administrator", "Designer", "Deployer");
|
||||
ExpandNavSection(cut, "Audit");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
@@ -145,7 +145,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
||||
[Fact]
|
||||
public void NavMenu_Contains_ConfigurationAuditLog_Link_UnderAuditGroup()
|
||||
{
|
||||
var cut = RenderNavMenu("Admin", "Design", "Deployment");
|
||||
var cut = RenderNavMenu("Administrator", "Designer", "Deployer");
|
||||
ExpandNavSection(cut, "Audit");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
@@ -178,7 +178,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery($"correlationId={corr}", "Admin");
|
||||
var cut = RenderAuditLogPageWithQuery($"correlationId={corr}", "Administrator");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -201,7 +201,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Admin");
|
||||
var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Administrator");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -217,7 +217,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
||||
{
|
||||
_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
|
||||
// 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>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery($"parentExecutionId={parentExecutionId}", "Admin");
|
||||
var cut = RenderAuditLogPageWithQuery($"parentExecutionId={parentExecutionId}", "Administrator");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -255,7 +255,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
||||
{
|
||||
_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
|
||||
// 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>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery("target=ExternalSystem-Alpha", "Admin");
|
||||
var cut = RenderAuditLogPageWithQuery("target=ExternalSystem-Alpha", "Administrator");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -292,7 +292,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery("site=plant-a", "Admin");
|
||||
var cut = RenderAuditLogPageWithQuery("site=plant-a", "Administrator");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -314,7 +314,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery("status=Failed", "Admin");
|
||||
var cut = RenderAuditLogPageWithQuery("status=Failed", "Administrator");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -331,7 +331,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
||||
{
|
||||
_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
|
||||
// params present the page renders but does NOT call the query service
|
||||
@@ -348,7 +348,7 @@ public class AuditLogPageScaffoldTests : BunitContext
|
||||
{
|
||||
_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
|
||||
// query service must NOT be hit because nothing told us to load.
|
||||
|
||||
+3
-3
@@ -88,7 +88,7 @@ public class TransportExportPageTests : BunitContext
|
||||
SourceEnvironment = "test-cluster",
|
||||
}));
|
||||
|
||||
var principal = BuildPrincipal("alice", "Design");
|
||||
var principal = BuildPrincipal("alice", "Designer");
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
|
||||
Services.AddAuthorizationCore();
|
||||
}
|
||||
@@ -304,8 +304,8 @@ public class TransportExportPageTests : BunitContext
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var authService = provider.GetRequiredService<IAuthorizationService>();
|
||||
|
||||
// Audit-only user — has a role but it isn't Design.
|
||||
var principal = BuildPrincipal("bob", "Audit");
|
||||
// Administrator user — has a role but it isn't Designer.
|
||||
var principal = BuildPrincipal("bob", "Administrator");
|
||||
var result = await authService.AuthorizeAsync(
|
||||
principal, null, AuthorizationPolicies.RequireDesign);
|
||||
|
||||
|
||||
+2
-2
@@ -62,7 +62,7 @@ public class TransportImportPageTests : BunitContext
|
||||
dbContext.Database.EnsureCreated();
|
||||
Services.AddSingleton(dbContext);
|
||||
|
||||
var principal = BuildPrincipal("alice", "Admin");
|
||||
var principal = BuildPrincipal("alice", "Administrator");
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
|
||||
Services.AddAuthorizationCore();
|
||||
}
|
||||
@@ -299,7 +299,7 @@ public class TransportImportPageTests : BunitContext
|
||||
var authService = provider.GetRequiredService<IAuthorizationService>();
|
||||
|
||||
// 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(
|
||||
principal, null, AuthorizationPolicies.RequireAdmin);
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ public class ExecutionTreePageTests : BunitContext
|
||||
Node(child, root),
|
||||
}));
|
||||
|
||||
var cut = RenderPage($"executionId={child}", "Admin");
|
||||
var cut = RenderPage($"executionId={child}", "Administrator");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -96,7 +96,7 @@ public class ExecutionTreePageTests : BunitContext
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
var cut = RenderPage(query: null, "Admin");
|
||||
var cut = RenderPage(query: null, "Administrator");
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup));
|
||||
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||
@@ -107,7 +107,7 @@ public class ExecutionTreePageTests : BunitContext
|
||||
{
|
||||
_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));
|
||||
_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.
|
||||
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.
|
||||
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>()));
|
||||
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.WaitForAssertion(() =>
|
||||
|
||||
@@ -82,7 +82,7 @@ public class HealthPageTests : BunitContext
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Admin"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Administrator"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
|
||||
@@ -69,7 +69,7 @@ public class NotificationKpisPageTests : BunitContext
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployment"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployer"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
|
||||
@@ -23,7 +23,7 @@ public class NotificationListsPageTests : BunitContext
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Design"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Designer"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ public class NotificationReportDetailModalTests : BunitContext
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployment"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployer"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
|
||||
@@ -74,7 +74,7 @@ public class NotificationReportPageTests : BunitContext
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployment"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployer"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
|
||||
@@ -173,7 +173,7 @@ public sealed class QueryStringDrillInTests
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployment"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployer"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
@@ -241,7 +241,7 @@ public sealed class QueryStringDrillInTests
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
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"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
|
||||
|
||||
@@ -89,7 +89,7 @@ public class SiteCallsReportPageTests : BunitContext
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployment"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployer"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
@@ -494,7 +494,7 @@ public class SiteCallsReportPageTests : BunitContext
|
||||
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "scoped"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployment"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployer"),
|
||||
new Claim(JwtTokenService.SiteIdClaimType, "1"), // Plant A only
|
||||
}, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));
|
||||
|
||||
@@ -21,7 +21,7 @@ public class SmtpConfigurationPageTests : BunitContext
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Admin"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Administrator"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
|
||||
@@ -52,7 +52,7 @@ public class TemplatesPageTests : BunitContext
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Design")
|
||||
new Claim(JwtTokenService.RoleClaimType, "Designer")
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, "TestAuth");
|
||||
var user = new ClaimsPrincipal(identity);
|
||||
|
||||
@@ -88,7 +88,7 @@ public class TopologyPageTests : BunitContext
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployment")
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployer")
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
@@ -217,7 +217,7 @@ public class TopologyPageTests : BunitContext
|
||||
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
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.
|
||||
new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.SiteIdClaimType, "1"),
|
||||
}, "TestAuth"));
|
||||
|
||||
@@ -38,21 +38,21 @@ public class SecurityRepositoryTests : IDisposable
|
||||
[Fact]
|
||||
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.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetMappingByIdAsync(mapping.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("CN=Admins,DC=test", loaded.LdapGroupName);
|
||||
Assert.Equal("Admin", loaded.Role);
|
||||
Assert.Equal("Administrator", loaded.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllMappings_ReturnsAll()
|
||||
{
|
||||
await _repository.AddMappingAsync(new LdapGroupMapping("Group1", "Admin"));
|
||||
await _repository.AddMappingAsync(new LdapGroupMapping("Group2", "Design"));
|
||||
await _repository.AddMappingAsync(new LdapGroupMapping("Group1", "Administrator"));
|
||||
await _repository.AddMappingAsync(new LdapGroupMapping("Group2", "Designer"));
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
// +1 for seed data
|
||||
@@ -63,12 +63,12 @@ public class SecurityRepositoryTests : IDisposable
|
||||
[Fact]
|
||||
public async Task GetMappingsByRole_FiltersCorrectly()
|
||||
{
|
||||
await _repository.AddMappingAsync(new LdapGroupMapping("Designers", "Design"));
|
||||
await _repository.AddMappingAsync(new LdapGroupMapping("Deployers", "Deployment"));
|
||||
await _repository.AddMappingAsync(new LdapGroupMapping("Designers", "Designer"));
|
||||
await _repository.AddMappingAsync(new LdapGroupMapping("Deployers", "Deployer"));
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var designMappings = await _repository.GetMappingsByRoleAsync("Design");
|
||||
// Seed data includes "SCADA-Designers" with role "Design", plus the one we added
|
||||
var designMappings = await _repository.GetMappingsByRoleAsync("Designer");
|
||||
// Seed data includes "SCADA-Designers" with role "Designer", plus the one we added
|
||||
Assert.Equal(2, designMappings.Count);
|
||||
Assert.Contains(designMappings, m => m.LdapGroupName == "Designers");
|
||||
}
|
||||
@@ -76,23 +76,23 @@ public class SecurityRepositoryTests : IDisposable
|
||||
[Fact]
|
||||
public async Task UpdateMapping_PersistsChange()
|
||||
{
|
||||
var mapping = new LdapGroupMapping("OldGroup", "Admin");
|
||||
var mapping = new LdapGroupMapping("OldGroup", "Administrator");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
mapping.Role = "Design";
|
||||
mapping.Role = "Designer";
|
||||
await _repository.UpdateMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
_context.ChangeTracker.Clear();
|
||||
var loaded = await _repository.GetMappingByIdAsync(mapping.Id);
|
||||
Assert.Equal("Design", loaded!.Role);
|
||||
Assert.Equal("Designer", loaded!.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteMapping_RemovesEntity()
|
||||
{
|
||||
var mapping = new LdapGroupMapping("ToDelete", "Admin");
|
||||
var mapping = new LdapGroupMapping("ToDelete", "Administrator");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
@@ -108,7 +108,7 @@ public class SecurityRepositoryTests : IDisposable
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
_context.Sites.Add(site);
|
||||
var mapping = new LdapGroupMapping("Deployers", "Deployment");
|
||||
var mapping = new LdapGroupMapping("Deployers", "Deployer");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
@@ -126,7 +126,7 @@ public class SecurityRepositoryTests : IDisposable
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
_context.Sites.Add(site);
|
||||
var mapping = new LdapGroupMapping("Group", "Deployment");
|
||||
var mapping = new LdapGroupMapping("Group", "Deployer");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
@@ -145,7 +145,7 @@ public class SecurityRepositoryTests : IDisposable
|
||||
var site1 = new Site("Site1", "SITE-001");
|
||||
var site2 = new Site("Site2", "SITE-002");
|
||||
_context.Sites.AddRange(site1, site2);
|
||||
var mapping = new LdapGroupMapping("Group", "Deployment");
|
||||
var mapping = new LdapGroupMapping("Group", "Deployer");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
@@ -167,7 +167,7 @@ public class SecurityRepositoryTests : IDisposable
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
_context.Sites.Add(site);
|
||||
var mapping = new LdapGroupMapping("Group", "Deployment");
|
||||
var mapping = new LdapGroupMapping("Group", "Deployer");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ public class SeedDataTests : IDisposable
|
||||
.SingleOrDefaultAsync(m => m.LdapGroupName == "SCADA-Admins");
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ public class DbContextTests : IDisposable
|
||||
_context.Sites.Add(site);
|
||||
_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.SaveChanges();
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ public class AuditTransactionTests : IClassFixture<ScadaBridgeWebApplicationFact
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
|
||||
// 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 auditService.LogAsync(
|
||||
@@ -37,7 +37,7 @@ public class AuditTransactionTests : IClassFixture<ScadaBridgeWebApplicationFact
|
||||
entityType: "LdapGroupMapping",
|
||||
entityId: "0", // ID not yet assigned
|
||||
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
|
||||
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>();
|
||||
|
||||
// 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 auditService.LogAsync("test", "Create", "LdapGroupMapping", "0", "orphan-group", null);
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
|
||||
var token = jwtService.GenerateToken(
|
||||
displayName: "Test User",
|
||||
username: "testuser",
|
||||
roles: new[] { "Admin", "Design" },
|
||||
roles: new[] { "Administrator", "Designer" },
|
||||
permittedSiteIds: null);
|
||||
|
||||
Assert.NotNull(token);
|
||||
@@ -78,8 +78,8 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
|
||||
|
||||
Assert.Equal("Test User", displayName);
|
||||
Assert.Equal("testuser", username);
|
||||
Assert.Contains("Admin", roles);
|
||||
Assert.Contains("Design", roles);
|
||||
Assert.Contains("Administrator", roles);
|
||||
Assert.Contains("Designer", roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -91,7 +91,7 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
|
||||
var token = jwtService.GenerateToken(
|
||||
displayName: "Deployer",
|
||||
username: "deployer1",
|
||||
roles: new[] { "Deployment" },
|
||||
roles: new[] { "Deployer" },
|
||||
permittedSiteIds: new[] { "1", "3" });
|
||||
|
||||
var principal = jwtService.ValidateToken(token);
|
||||
|
||||
@@ -33,7 +33,7 @@ public class CentralFailoverTests
|
||||
var token = jwtServiceA.GenerateToken(
|
||||
displayName: "Failover User",
|
||||
username: "failover_test",
|
||||
roles: new[] { "Admin" },
|
||||
roles: new[] { "Administrator" },
|
||||
permittedSiteIds: null);
|
||||
|
||||
// Validate with a second instance (same signing key = simulated failover)
|
||||
@@ -54,7 +54,7 @@ public class CentralFailoverTests
|
||||
var token = jwtService.GenerateToken(
|
||||
displayName: "Scoped User",
|
||||
username: "scoped_user",
|
||||
roles: new[] { "Deployment" },
|
||||
roles: new[] { "Deployer" },
|
||||
permittedSiteIds: new[] { "site-1", "site-2", "site-5" });
|
||||
|
||||
var principal = jwtService.ValidateToken(token);
|
||||
@@ -77,7 +77,7 @@ public class CentralFailoverTests
|
||||
var token = jwtServiceA.GenerateToken(
|
||||
displayName: "User",
|
||||
username: "user",
|
||||
roles: new[] { "Admin" },
|
||||
roles: new[] { "Administrator" },
|
||||
permittedSiteIds: null);
|
||||
|
||||
// Token from A should NOT validate on B (different key)
|
||||
@@ -109,7 +109,7 @@ public class CentralFailoverTests
|
||||
var token = jwtService.GenerateToken(
|
||||
displayName: "Expired User",
|
||||
username: "expired_user",
|
||||
roles: new[] { "Admin" },
|
||||
roles: new[] { "Administrator" },
|
||||
permittedSiteIds: null);
|
||||
|
||||
var principal = jwtService.ValidateToken(token);
|
||||
@@ -145,7 +145,7 @@ public class CentralFailoverTests
|
||||
var token = jwtService.GenerateToken(
|
||||
displayName: "User",
|
||||
username: "user",
|
||||
roles: new[] { "Admin" },
|
||||
roles: new[] { "Administrator" },
|
||||
permittedSiteIds: null);
|
||||
|
||||
var principal = jwtService.ValidateToken(token);
|
||||
|
||||
@@ -48,7 +48,7 @@ public class SecurityHardeningTests
|
||||
var token = jwtService.GenerateToken(
|
||||
displayName: "Test",
|
||||
username: "test",
|
||||
roles: new[] { "Admin" },
|
||||
roles: new[] { "Administrator" },
|
||||
permittedSiteIds: null);
|
||||
|
||||
Assert.NotNull(token);
|
||||
@@ -96,7 +96,7 @@ public class SecurityHardeningTests
|
||||
var token = jwtService.GenerateToken(
|
||||
displayName: "Test",
|
||||
username: "test",
|
||||
roles: new[] { "Admin" },
|
||||
roles: new[] { "Administrator" },
|
||||
permittedSiteIds: null);
|
||||
|
||||
// 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(
|
||||
displayName: "User",
|
||||
username: "user",
|
||||
roles: new[] { "Admin" },
|
||||
roles: new[] { "Administrator" },
|
||||
permittedSiteIds: null);
|
||||
|
||||
// Tamper with the token payload (second segment)
|
||||
@@ -150,7 +150,7 @@ public class SecurityHardeningTests
|
||||
var originalToken = jwtService.GenerateToken(
|
||||
displayName: "Original User",
|
||||
username: "orig_user",
|
||||
roles: new[] { "Admin", "Design" },
|
||||
roles: new[] { "Administrator", "Designer" },
|
||||
permittedSiteIds: new[] { "site-1" });
|
||||
|
||||
var principal = jwtService.ValidateToken(originalToken);
|
||||
@@ -159,7 +159,7 @@ public class SecurityHardeningTests
|
||||
// Refresh the token
|
||||
var refreshedToken = jwtService.RefreshToken(
|
||||
principal!,
|
||||
new[] { "Admin", "Design" },
|
||||
new[] { "Administrator", "Designer" },
|
||||
new[] { "site-1" });
|
||||
|
||||
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
|
||||
/// 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
|
||||
/// 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>
|
||||
public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
{
|
||||
@@ -48,7 +48,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
public void CreateApiKey_ReturnsKeyIdAndOneTimeToken()
|
||||
{
|
||||
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));
|
||||
|
||||
@@ -73,7 +73,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
public void CreateApiKey_AuditsTheCreate()
|
||||
{
|
||||
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));
|
||||
|
||||
_auditService.Received(1).LogAsync(
|
||||
@@ -84,7 +84,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
public void CreateApiKey_ResponseDoesNotEchoAHash()
|
||||
{
|
||||
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));
|
||||
|
||||
@@ -100,7 +100,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
_admin.Seed("key-2", "Service B", enabled: false, "M3");
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new ListApiKeysCommand(), "Admin"));
|
||||
actor.Tell(Envelope(new ListApiKeysCommand(), "Administrator"));
|
||||
|
||||
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");
|
||||
|
||||
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));
|
||||
|
||||
@@ -145,7 +145,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
_admin.Seed("key-1", "Service A", enabled: true, "M1");
|
||||
|
||||
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));
|
||||
|
||||
@@ -162,7 +162,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
_admin.Seed("key-1", "Service A", enabled: true, "Old1", "Old2");
|
||||
|
||||
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));
|
||||
|
||||
@@ -188,7 +188,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
{
|
||||
// No keys seeded — "key-unknown" does not exist.
|
||||
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));
|
||||
|
||||
@@ -202,7 +202,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
public void SetApiKeyMethods_UnknownKey_ReturnsManagementError_AndDoesNotAudit()
|
||||
{
|
||||
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));
|
||||
|
||||
@@ -215,7 +215,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
public void DeleteApiKey_UnknownKey_ReturnsManagementError_AndDoesNotAudit()
|
||||
{
|
||||
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));
|
||||
|
||||
@@ -232,7 +232,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
public void CreateApiKey_EmptyMethods_ReturnsManagementError()
|
||||
{
|
||||
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));
|
||||
|
||||
@@ -249,7 +249,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
_admin.Seed("key-1", "Service A", enabled: true, "M1");
|
||||
|
||||
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));
|
||||
|
||||
@@ -265,11 +265,11 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
public void EveryApiKeyCommand_RequiresAdminRole(object command)
|
||||
{
|
||||
var actor = CreateActor();
|
||||
// A Design-role caller (not Admin) must be rejected for every API-key command.
|
||||
actor.Tell(Envelope(command, "Design"));
|
||||
// A Designer-role caller (not Administrator) must be rejected for every API-key command.
|
||||
actor.Tell(Envelope(command, "Designer"));
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Admin", response.Message);
|
||||
Assert.Contains("Administrator", response.Message);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> AllApiKeyCommands() => new[]
|
||||
|
||||
@@ -125,7 +125,7 @@ public class AuditEndpointsTests
|
||||
public async Task Query_ValidParams_ReturnsJsonPage()
|
||||
{
|
||||
var (client, _, host) = await BuildHostAsync(
|
||||
roles: new[] { "Audit" },
|
||||
roles: new[] { "Administrator" },
|
||||
queryPages: new[] { (IReadOnlyList<AuditEvent>)new[] { SampleEvent() } });
|
||||
using (host)
|
||||
{
|
||||
@@ -160,7 +160,7 @@ public class AuditEndpointsTests
|
||||
new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc)),
|
||||
};
|
||||
var (client, repo, host) = await BuildHostAsync(
|
||||
roles: new[] { "Audit" },
|
||||
roles: new[] { "Administrator" },
|
||||
queryPages: new[] { pageOne });
|
||||
using (host)
|
||||
{
|
||||
@@ -193,9 +193,9 @@ public class AuditEndpointsTests
|
||||
[Fact]
|
||||
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.
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Design" });
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Designer" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/query"));
|
||||
@@ -206,7 +206,7 @@ public class AuditEndpointsTests
|
||||
[Fact]
|
||||
public async Task Query_WithoutCredentials_Returns401()
|
||||
{
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" });
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Administrator" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/query", credential: ""));
|
||||
@@ -215,10 +215,11 @@ public class AuditEndpointsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_AuditReadOnlyRole_IsAllowed()
|
||||
public async Task Query_ViewerRole_IsAllowed()
|
||||
{
|
||||
// AuditReadOnly satisfies OperationalAudit (read) — query must succeed.
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "AuditReadOnly" });
|
||||
// Viewer (post Task 1.7 home of the former AuditReadOnly role) satisfies
|
||||
// OperationalAudit (read) — query must succeed.
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Viewer" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/query"));
|
||||
@@ -234,7 +235,7 @@ public class AuditEndpointsTests
|
||||
public async Task Export_Csv_StreamsContent_WithCsvContentType()
|
||||
{
|
||||
var (client, _, host) = await BuildHostAsync(
|
||||
roles: new[] { "Audit" },
|
||||
roles: new[] { "Administrator" },
|
||||
queryPages: new[]
|
||||
{
|
||||
(IReadOnlyList<AuditEvent>)new[] { SampleEvent() },
|
||||
@@ -263,7 +264,7 @@ public class AuditEndpointsTests
|
||||
{
|
||||
// No format= param → csv default.
|
||||
var (client, _, host) = await BuildHostAsync(
|
||||
roles: new[] { "Audit" },
|
||||
roles: new[] { "Administrator" },
|
||||
queryPages: new[] { (IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>() });
|
||||
using (host)
|
||||
{
|
||||
@@ -277,7 +278,7 @@ public class AuditEndpointsTests
|
||||
public async Task Export_Jsonl_StreamsOnePerLine()
|
||||
{
|
||||
var (client, _, host) = await BuildHostAsync(
|
||||
roles: new[] { "Audit" },
|
||||
roles: new[] { "Administrator" },
|
||||
queryPages: new[]
|
||||
{
|
||||
(IReadOnlyList<AuditEvent>)new[]
|
||||
@@ -313,7 +314,7 @@ public class AuditEndpointsTests
|
||||
{
|
||||
// Parquet archival is deferred to v1.x (Component-AuditLog.md) — no
|
||||
// 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)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/export?format=parquet"));
|
||||
@@ -327,9 +328,10 @@ public class AuditEndpointsTests
|
||||
[Fact]
|
||||
public async Task Export_WithoutAuditExport_Returns403()
|
||||
{
|
||||
// AuditReadOnly grants read (OperationalAudit) but NOT bulk export
|
||||
// (AuditExport) — the export endpoint must 403.
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "AuditReadOnly" });
|
||||
// Viewer (former AuditReadOnly) grants read (OperationalAudit) but NOT
|
||||
// bulk export (AuditExport) — the export endpoint must 403. This is the
|
||||
// preserved half-SoD after the Task 1.7 AuditReadOnly→Viewer collapse.
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Viewer" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/export?format=csv"));
|
||||
@@ -340,7 +342,7 @@ public class AuditEndpointsTests
|
||||
[Fact]
|
||||
public async Task Export_UnsupportedFormat_Returns400()
|
||||
{
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" });
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Administrator" });
|
||||
using (host)
|
||||
{
|
||||
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
|
||||
// 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)
|
||||
{
|
||||
var response = await client.SendAsync(Get(
|
||||
@@ -537,11 +539,11 @@ public class AuditEndpointsTests
|
||||
[Fact]
|
||||
public void ApplySiteScope_SystemWideUser_ReturnsFilterUnchanged()
|
||||
{
|
||||
// Empty PermittedSiteIds is the system-wide signal (Admin, system-wide
|
||||
// Deployment, audit roles with no scope rules attached). The filter
|
||||
// should pass through with no restriction added.
|
||||
// Empty PermittedSiteIds is the system-wide signal (Administrator,
|
||||
// system-wide Deployer, audit roles with no scope rules attached). The
|
||||
// filter should pass through with no restriction added.
|
||||
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 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
|
||||
// user could read every site's rows.
|
||||
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 result = AuditEndpoints.ApplySiteScope(filter, user);
|
||||
@@ -571,7 +573,7 @@ public class AuditEndpointsTests
|
||||
public void ApplySiteScope_ScopedUser_ExplicitInScopeFilter_KeptVerbatim()
|
||||
{
|
||||
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 result = AuditEndpoints.ApplySiteScope(filter, user);
|
||||
@@ -586,7 +588,7 @@ public class AuditEndpointsTests
|
||||
// Caller explicitly asked for a site they cannot see — the helper signals
|
||||
// "403" by returning null rather than silently producing an empty page.
|
||||
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 result = AuditEndpoints.ApplySiteScope(filter, user);
|
||||
@@ -598,7 +600,7 @@ public class AuditEndpointsTests
|
||||
public void ApplySiteScope_ScopedUser_MixedInAndOutOfScopeFilter_IntersectedToInScopeOnly()
|
||||
{
|
||||
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 result = AuditEndpoints.ApplySiteScope(filter, user);
|
||||
|
||||
@@ -12,7 +12,7 @@ public class DebugStreamHubTests
|
||||
public void IsInstanceAccessAllowed_SiteScopedUser_InScopeInstance_Allowed()
|
||||
{
|
||||
var allowed = DebugStreamHub.IsInstanceAccessAllowed(
|
||||
roles: new[] { "Deployment" },
|
||||
roles: new[] { "Deployer" },
|
||||
permittedSiteIds: new[] { "1", "2" },
|
||||
instanceSiteId: 2);
|
||||
|
||||
@@ -23,7 +23,7 @@ public class DebugStreamHubTests
|
||||
public void IsInstanceAccessAllowed_SiteScopedUser_OutOfScopeInstance_Denied()
|
||||
{
|
||||
var allowed = DebugStreamHub.IsInstanceAccessAllowed(
|
||||
roles: new[] { "Deployment" },
|
||||
roles: new[] { "Deployer" },
|
||||
permittedSiteIds: new[] { "1", "2" },
|
||||
instanceSiteId: 99);
|
||||
|
||||
@@ -33,9 +33,9 @@ public class DebugStreamHubTests
|
||||
[Fact]
|
||||
public void IsInstanceAccessAllowed_SystemWideDeployment_AnySiteAllowed()
|
||||
{
|
||||
// Empty permitted set == system-wide Deployment.
|
||||
// Empty permitted set == system-wide Deployer.
|
||||
var allowed = DebugStreamHub.IsInstanceAccessAllowed(
|
||||
roles: new[] { "Deployment" },
|
||||
roles: new[] { "Deployer" },
|
||||
permittedSiteIds: Array.Empty<string>(),
|
||||
instanceSiteId: 99);
|
||||
|
||||
@@ -46,7 +46,7 @@ public class DebugStreamHubTests
|
||||
public void IsInstanceAccessAllowed_AdminRole_BypassesSiteScope()
|
||||
{
|
||||
var allowed = DebugStreamHub.IsInstanceAccessAllowed(
|
||||
roles: new[] { "Admin", "Deployment" },
|
||||
roles: new[] { "Administrator", "Deployer" },
|
||||
permittedSiteIds: new[] { "1" },
|
||||
instanceSiteId: 99);
|
||||
|
||||
@@ -57,7 +57,7 @@ public class DebugStreamHubTests
|
||||
public void IsInstanceAccessAllowed_AdminRoleCheck_IsCaseInsensitive()
|
||||
{
|
||||
var allowed = DebugStreamHub.IsInstanceAccessAllowed(
|
||||
roles: new[] { "admin" },
|
||||
roles: new[] { "administrator" },
|
||||
permittedSiteIds: new[] { "1" },
|
||||
instanceSiteId: 99);
|
||||
|
||||
|
||||
@@ -61,12 +61,12 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
public void CreateSiteCommand_WithDesignRole_ReturnsUnauthorized()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new CreateSiteCommand("Site1", "SITE1", "Desc"), "Design");
|
||||
var envelope = Envelope(new CreateSiteCommand("Site1", "SITE1", "Desc"), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Admin", response.Message);
|
||||
Assert.Contains("Administrator", response.Message);
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
@@ -79,19 +79,19 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Admin", response.Message);
|
||||
Assert.Contains("Administrator", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeploymentCommand_WithDesignRole_ReturnsUnauthorized()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new CreateInstanceCommand("Inst1", 1, 1), "Design");
|
||||
var envelope = Envelope(new CreateInstanceCommand("Inst1", 1, 1), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Deployment", response.Message);
|
||||
Assert.Contains("Deployer", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -109,19 +109,19 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Admin", response.Message);
|
||||
Assert.Contains("Administrator", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryAuditLogCommand_WithDeploymentRole_ReturnsUnauthorized()
|
||||
{
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new ListSitesCommand(), "Design");
|
||||
var envelope = Envelope(new ListSitesCommand(), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -220,7 +220,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new CreateInstanceCommand("Pump1", 1, 1),
|
||||
"Deployment");
|
||||
"Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -264,7 +264,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new CreateInstanceCommand("BadInst", 99, 1),
|
||||
"Deployment");
|
||||
"Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -280,16 +280,16 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
[Fact]
|
||||
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 envelope = Envelope(
|
||||
new CreateTemplateCommand("T1", null, null),
|
||||
"Admin");
|
||||
"Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Design", response.Message);
|
||||
Assert.Contains("Designer", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -305,7 +305,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new CreateSiteCommand("NewSite", "NS1", "Desc"),
|
||||
"Admin");
|
||||
"Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -324,10 +324,10 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
// "admin" lowercase should still match "Admin" requirement
|
||||
// "administrator" lowercase should still match "Administrator" requirement
|
||||
var envelope = Envelope(
|
||||
new CreateSiteCommand("Site2", "S2", null),
|
||||
"admin");
|
||||
"administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -343,84 +343,84 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
public void SharedScriptCreate_WithAdminRole_ReturnsUnauthorized()
|
||||
{
|
||||
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);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Design", response.Message);
|
||||
Assert.Contains("Designer", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DatabaseConnectionCreate_WithDeploymentRole_ReturnsUnauthorized()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new CreateDatabaseConnectionDefCommand("DB1", "Server=test"), "Deployment");
|
||||
var envelope = Envelope(new CreateDatabaseConnectionDefCommand("DB1", "Server=test"), "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Design", response.Message);
|
||||
Assert.Contains("Designer", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApiMethodCreate_WithAdminRole_ReturnsUnauthorized()
|
||||
{
|
||||
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);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Design", response.Message);
|
||||
Assert.Contains("Designer", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddTemplateAttribute_WithDeploymentRole_ReturnsUnauthorized()
|
||||
{
|
||||
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);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Design", response.Message);
|
||||
Assert.Contains("Designer", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateApiKey_WithDesignRole_ReturnsUnauthorized()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new UpdateApiKeyCommand("key-1", true), "Design");
|
||||
var envelope = Envelope(new UpdateApiKeyCommand("key-1", true), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Admin", response.Message);
|
||||
Assert.Contains("Administrator", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddScopeRule_WithDesignRole_ReturnsUnauthorized()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new AddScopeRuleCommand(1, 1), "Design");
|
||||
var envelope = Envelope(new AddScopeRuleCommand(1, 1), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Admin", response.Message);
|
||||
Assert.Contains("Administrator", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateArea_WithAdminRole_ReturnsUnauthorized()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new UpdateAreaCommand(1, "NewName"), "Admin");
|
||||
var envelope = Envelope(new UpdateAreaCommand(1, "NewName"), "Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
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);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new ListScopeRulesCommand(1), "Admin");
|
||||
var envelope = Envelope(new ListScopeRulesCommand(1), "Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -545,7 +545,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
.Returns(new Instance("Pump7") { Id = 7, SiteId = 2 });
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployment");
|
||||
var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -560,7 +560,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
.Returns(new Instance("Pump7") { Id = 7, SiteId = 1 });
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployment");
|
||||
var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -573,7 +573,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
AddSiteRepoWithSite(2, "SITE2");
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Deployment");
|
||||
var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -591,7 +591,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
_services.AddScoped(_ => uiRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new ListAreasCommand(2), new[] { "1" }, "Deployment");
|
||||
var envelope = ScopedEnvelope(new ListAreasCommand(2), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -608,7 +608,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetDataConnectionCommand(5), new[] { "1" }, "Deployment");
|
||||
var envelope = ScopedEnvelope(new GetDataConnectionCommand(5), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -623,7 +623,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
AddSiteRepoWithSite(2, "SITE2");
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Admin");
|
||||
var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -637,7 +637,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
AddSiteRepoWithSite(2, "SITE2");
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new QueryEventLogsCommand("SITE2"), new[] { "1" }, "Deployment");
|
||||
var envelope = ScopedEnvelope(new QueryEventLogsCommand("SITE2"), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -651,7 +651,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
AddSiteRepoWithSite(2, "SITE2");
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new QueryParkedMessagesCommand("SITE2"), new[] { "1" }, "Deployment");
|
||||
var envelope = ScopedEnvelope(new QueryParkedMessagesCommand("SITE2"), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -665,7 +665,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
AddSiteRepoWithSite(2, "SITE2");
|
||||
|
||||
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);
|
||||
|
||||
@@ -679,7 +679,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
AddSiteRepoWithSite(2, "SITE2");
|
||||
|
||||
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);
|
||||
|
||||
@@ -696,7 +696,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
AddSiteRepoWithSite(2, "SITE2");
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new DebugSnapshotCommand(9), new[] { "1" }, "Deployment");
|
||||
var envelope = ScopedEnvelope(new DebugSnapshotCommand(9), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -808,12 +808,12 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
public void QueryDeployments_WithDesignRole_ReturnsUnauthorized()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new QueryDeploymentsCommand(), "Design");
|
||||
var envelope = Envelope(new QueryDeploymentsCommand(), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Deployment", response.Message);
|
||||
Assert.Contains("Deployer", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -828,7 +828,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new QueryDeploymentsCommand(), "Deployment");
|
||||
var envelope = Envelope(new QueryDeploymentsCommand(), "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -846,7 +846,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new QueryDeploymentsCommand(InstanceId: 5), "Deployment");
|
||||
var envelope = Envelope(new QueryDeploymentsCommand(InstanceId: 5), "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -864,7 +864,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
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);
|
||||
|
||||
@@ -885,7 +885,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
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);
|
||||
|
||||
@@ -914,7 +914,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployment");
|
||||
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -951,7 +951,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployment");
|
||||
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -974,7 +974,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
_services.AddScoped(_ => deployRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Admin", "Deployment");
|
||||
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Administrator", "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -1006,7 +1006,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
// "Good" is valid, "Bogus" is not — the whole command must fail with
|
||||
// nothing written.
|
||||
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);
|
||||
|
||||
@@ -1036,7 +1036,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
|
||||
var actor = CreateActor();
|
||||
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);
|
||||
|
||||
@@ -1095,7 +1095,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new UpdateSmtpConfigCommand(1, "new.example.com", 465, "Basic", "new@example.com", "SSL", "user:pass"),
|
||||
"Design");
|
||||
"Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -1125,7 +1125,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new UpdateSmtpConfigCommand(1, "new.example.com", 465, "Basic", "new@example.com"),
|
||||
"Design");
|
||||
"Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -1148,7 +1148,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
.Returns((Template?)null);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new CreateInstanceCommand("BadInst", 99, 1), "Deployment");
|
||||
var envelope = Envelope(new CreateInstanceCommand("BadInst", 99, 1), "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -1228,12 +1228,12 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
// ExportBundle requires the Design role; an Admin-only caller is rejected.
|
||||
AddBundleSubstitutes();
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(AllExportCommand(), "Admin");
|
||||
var envelope = Envelope(AllExportCommand(), "Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Design", response.Message);
|
||||
Assert.Contains("Designer", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1244,12 +1244,12 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
// configuration).
|
||||
AddBundleSubstitutes();
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new PreviewBundleCommand("AA==", null), "Design");
|
||||
var envelope = Envelope(new PreviewBundleCommand("AA==", null), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Admin", response.Message);
|
||||
Assert.Contains("Administrator", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1257,12 +1257,12 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
{
|
||||
AddBundleSubstitutes();
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new ImportBundleCommand("AA==", null, "skip"), "Design");
|
||||
var envelope = Envelope(new ImportBundleCommand("AA==", null, "skip"), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Admin", response.Message);
|
||||
Assert.Contains("Administrator", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1286,7 +1286,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
SourceEnvironment: "test-env");
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(cmd, "Design");
|
||||
var envelope = Envelope(cmd, "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -1331,7 +1331,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
// base64 check before reaching the importer.
|
||||
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new ImportBundleCommand(payload, null, "skip"), "Admin");
|
||||
var envelope = Envelope(new ImportBundleCommand(payload, null, "skip"), "Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -1399,7 +1399,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var actor = CreateActor();
|
||||
// "overwrite" policy so the final (Identical) row would otherwise differ
|
||||
// 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);
|
||||
|
||||
@@ -1425,7 +1425,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, "desc", false),
|
||||
"Design");
|
||||
"Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -1440,12 +1440,12 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, null, false),
|
||||
"Deployment");
|
||||
"Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Design", response.Message);
|
||||
Assert.Contains("Designer", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1473,7 +1473,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null),
|
||||
"Deployment");
|
||||
"Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
@@ -1488,11 +1488,11 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null),
|
||||
"Design");
|
||||
"Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
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.
|
||||
var mappings = new List<LdapGroupMapping>
|
||||
{
|
||||
Mapping(1, "SCADA-Admins", Roles.Admin),
|
||||
Mapping(2, "SiteDeployers", Roles.Deployment),
|
||||
Mapping(1, "SCADA-Admins", Roles.Administrator),
|
||||
Mapping(2, "SiteDeployers", Roles.Deployer),
|
||||
};
|
||||
var scopeRules = new Dictionary<int, IReadOnlyList<SiteScopeRule>>
|
||||
{
|
||||
@@ -91,8 +91,8 @@ public class ScadaBridgeGroupRoleMapperTests
|
||||
|
||||
// Roles: same set as RoleMapper.
|
||||
Assert.Equal(expected.Roles.OrderBy(r => r), mapping.Roles.OrderBy(r => r));
|
||||
Assert.Contains(Roles.Admin, mapping.Roles);
|
||||
Assert.Contains(Roles.Deployment, mapping.Roles);
|
||||
Assert.Contains(Roles.Administrator, mapping.Roles);
|
||||
Assert.Contains(Roles.Deployer, mapping.Roles);
|
||||
|
||||
// Scope: carries the full RoleMappingResult (no site-scope info lost).
|
||||
var scope = Assert.IsType<RoleMappingResult>(mapping.Scope);
|
||||
@@ -109,7 +109,7 @@ public class ScadaBridgeGroupRoleMapperTests
|
||||
// Unscoped Deployment mapping -> system-wide, empty PermittedSiteIds.
|
||||
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 roleMapper = new RoleMapper(repo);
|
||||
@@ -117,7 +117,7 @@ public class ScadaBridgeGroupRoleMapperTests
|
||||
|
||||
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);
|
||||
Assert.True(scope.IsSystemWideDeployment);
|
||||
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
|
||||
// the mapper.
|
||||
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>>());
|
||||
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
|
||||
// boundary now that the LDAP service no longer admits zero-group successes.
|
||||
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>>());
|
||||
var sut = new ScadaBridgeGroupRoleMapper(new RoleMapper(repo));
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ public class JwtTokenServiceTests
|
||||
var service = CreateService();
|
||||
var token = service.GenerateToken(
|
||||
"John Doe", "johnd",
|
||||
new[] { "Admin", "Design" },
|
||||
new[] { "Administrator", "Designer" },
|
||||
new[] { "1", "2" });
|
||||
|
||||
var principal = service.ValidateToken(token);
|
||||
@@ -126,8 +126,8 @@ public class JwtTokenServiceTests
|
||||
Assert.Equal("johnd", principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value);
|
||||
|
||||
var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
|
||||
Assert.Contains("Admin", roles);
|
||||
Assert.Contains("Design", roles);
|
||||
Assert.Contains("Administrator", roles);
|
||||
Assert.Contains("Designer", roles);
|
||||
|
||||
var siteIds = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToList();
|
||||
Assert.Contains("1", siteIds);
|
||||
@@ -140,7 +140,7 @@ public class JwtTokenServiceTests
|
||||
public void GenerateToken_NullSiteIds_NoSiteIdClaims()
|
||||
{
|
||||
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);
|
||||
|
||||
Assert.NotNull(principal);
|
||||
@@ -159,7 +159,7 @@ public class JwtTokenServiceTests
|
||||
public void ValidateToken_WrongKey_ReturnsNull()
|
||||
{
|
||||
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
|
||||
{
|
||||
@@ -176,7 +176,7 @@ public class JwtTokenServiceTests
|
||||
public void ValidateToken_UsesHmacSha256()
|
||||
{
|
||||
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
|
||||
var parts = token.Split('.');
|
||||
@@ -192,7 +192,7 @@ public class JwtTokenServiceTests
|
||||
options.JwtExpiryMinutes = 3; // Token expires in 3 min, threshold is 5 min
|
||||
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);
|
||||
|
||||
Assert.True(service.ShouldRefresh(principal!));
|
||||
@@ -202,7 +202,7 @@ public class JwtTokenServiceTests
|
||||
public void ShouldRefresh_TokenFarFromExpiry_ReturnsFalse()
|
||||
{
|
||||
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);
|
||||
|
||||
Assert.False(service.ShouldRefresh(principal!));
|
||||
@@ -212,7 +212,7 @@ public class JwtTokenServiceTests
|
||||
public void IsIdleTimedOut_RecentActivity_ReturnsFalse()
|
||||
{
|
||||
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);
|
||||
|
||||
Assert.False(service.IsIdleTimedOut(principal!));
|
||||
@@ -234,15 +234,15 @@ public class JwtTokenServiceTests
|
||||
public void RefreshToken_ReturnsNewTokenWithUpdatedClaims()
|
||||
{
|
||||
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 newToken = service.RefreshToken(principal!, new[] { "Admin", "Design" }, new[] { "1" });
|
||||
var newToken = service.RefreshToken(principal!, new[] { "Administrator", "Designer" }, new[] { "1" });
|
||||
Assert.NotNull(newToken);
|
||||
|
||||
var newPrincipal = service.ValidateToken(newToken!);
|
||||
var roles = newPrincipal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
|
||||
Assert.Contains("Design", roles);
|
||||
Assert.Contains("Designer", roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -251,7 +251,7 @@ public class JwtTokenServiceTests
|
||||
var service = CreateService();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -288,16 +288,16 @@ public class RoleMapperTests : IDisposable
|
||||
[Fact]
|
||||
public async Task MapGroupsToRoles_MultiRoleExtraction()
|
||||
{
|
||||
// Add mappings (note: seed data adds SCADA-Admins -> Admin)
|
||||
_context.LdapGroupMappings.Add(new LdapGroupMapping("Designers", "Design"));
|
||||
_context.LdapGroupMappings.Add(new LdapGroupMapping("Deployers", "Deployment"));
|
||||
// Add mappings (note: seed data adds SCADA-Admins -> Administrator)
|
||||
_context.LdapGroupMappings.Add(new LdapGroupMapping("Designers", Roles.Designer));
|
||||
_context.LdapGroupMappings.Add(new LdapGroupMapping("Deployers", Roles.Deployer));
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Admins", "Designers" });
|
||||
|
||||
Assert.Contains("Admin", result.Roles);
|
||||
Assert.Contains("Design", result.Roles);
|
||||
Assert.DoesNotContain("Deployment", result.Roles);
|
||||
Assert.Contains(Roles.Administrator, result.Roles);
|
||||
Assert.Contains(Roles.Designer, result.Roles);
|
||||
Assert.DoesNotContain(Roles.Deployer, result.Roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -306,7 +306,7 @@ public class RoleMapperTests : IDisposable
|
||||
var site1 = new Site("Site1", "S-001");
|
||||
var site2 = new Site("Site2", "S-002");
|
||||
_context.Sites.AddRange(site1, site2);
|
||||
_context.LdapGroupMappings.Add(new LdapGroupMapping("SiteDeployers", "Deployment"));
|
||||
_context.LdapGroupMappings.Add(new LdapGroupMapping("SiteDeployers", Roles.Deployer));
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
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" });
|
||||
|
||||
Assert.Contains("Deployment", result.Roles);
|
||||
Assert.Contains(Roles.Deployer, result.Roles);
|
||||
Assert.False(result.IsSystemWideDeployment);
|
||||
Assert.Contains(site1.Id.ToString(), result.PermittedSiteIds);
|
||||
Assert.Contains(site2.Id.ToString(), result.PermittedSiteIds);
|
||||
@@ -326,8 +326,8 @@ public class RoleMapperTests : IDisposable
|
||||
[Fact]
|
||||
public async Task MapGroupsToRoles_UserInBothSystemWideAndScopedDeploymentGroup_IsSystemWide()
|
||||
{
|
||||
// Security-016: a user in BOTH an unscoped Deployment mapping
|
||||
// (SCADA-Deploy-All, Id=3) AND a scoped Deployment mapping
|
||||
// Security-016: a user in BOTH an unscoped Deployer 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
|
||||
// grant. The union semantics now preserve the broader grant: the
|
||||
// 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" });
|
||||
|
||||
Assert.Contains("Deployment", result.Roles);
|
||||
Assert.Contains(Roles.Deployer, result.Roles);
|
||||
Assert.True(result.IsSystemWideDeployment);
|
||||
Assert.Empty(result.PermittedSiteIds);
|
||||
}
|
||||
@@ -349,12 +349,12 @@ public class RoleMapperTests : IDisposable
|
||||
[Fact]
|
||||
public async Task MapGroupsToRoles_SystemWideDeployment_NoScopeRules()
|
||||
{
|
||||
_context.LdapGroupMappings.Add(new LdapGroupMapping("GlobalDeployers", "Deployment"));
|
||||
_context.LdapGroupMappings.Add(new LdapGroupMapping("GlobalDeployers", Roles.Deployer));
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "GlobalDeployers" });
|
||||
|
||||
Assert.Contains("Deployment", result.Roles);
|
||||
Assert.Contains(Roles.Deployer, result.Roles);
|
||||
Assert.True(result.IsSystemWideDeployment);
|
||||
Assert.Empty(result.PermittedSiteIds);
|
||||
}
|
||||
@@ -380,10 +380,10 @@ public class RoleMapperTests : IDisposable
|
||||
[Fact]
|
||||
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" });
|
||||
|
||||
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 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));
|
||||
}
|
||||
|
||||
@@ -610,7 +610,7 @@ public class SecurityReviewRegressionTests2
|
||||
public void GenerateToken_SetsIssuerAndAudience()
|
||||
{
|
||||
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);
|
||||
Assert.Equal(JwtTokenService.TokenIssuer, jwt.Issuer);
|
||||
@@ -642,7 +642,7 @@ public class SecurityReviewRegressionTests2
|
||||
public void ValidateToken_AcceptsOwnIssuerAndAudience()
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -663,7 +663,7 @@ public class SecurityReviewRegressionTests2
|
||||
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
|
||||
}, "test"));
|
||||
|
||||
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
|
||||
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
|
||||
Assert.NotNull(refreshed);
|
||||
|
||||
var refreshedPrincipal = service.ValidateToken(refreshed!);
|
||||
@@ -690,7 +690,7 @@ public class SecurityReviewRegressionTests2
|
||||
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
|
||||
}, "test"));
|
||||
|
||||
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
|
||||
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
|
||||
var refreshedPrincipal = service.ValidateToken(refreshed!);
|
||||
|
||||
// Still 25 min idle after refresh — not reset to 0.
|
||||
@@ -715,7 +715,7 @@ public class SecurityReviewRegressionTests2
|
||||
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
|
||||
}, "test"));
|
||||
|
||||
var touched = service.RecordActivity(principal, new[] { "Admin" }, null);
|
||||
var touched = service.RecordActivity(principal, new[] { "Administrator" }, null);
|
||||
Assert.NotNull(touched);
|
||||
|
||||
var touchedPrincipal = service.ValidateToken(touched!);
|
||||
@@ -834,7 +834,7 @@ public class SecurityReviewRegressionTests4
|
||||
new Claim(JwtTokenService.LastActivityClaimType, idleActivity.ToString("o"))
|
||||
}, "test"));
|
||||
|
||||
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
|
||||
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
|
||||
|
||||
Assert.Null(refreshed);
|
||||
}
|
||||
@@ -853,7 +853,7 @@ public class SecurityReviewRegressionTests4
|
||||
new Claim(JwtTokenService.LastActivityClaimType, recentActivity.ToString("o"))
|
||||
}, "test"));
|
||||
|
||||
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
|
||||
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
|
||||
|
||||
Assert.NotNull(refreshed);
|
||||
}
|
||||
@@ -870,7 +870,7 @@ public class SecurityReviewRegressionTests4
|
||||
new Claim(JwtTokenService.UsernameClaimType, "user")
|
||||
}, "test"));
|
||||
|
||||
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
|
||||
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
|
||||
|
||||
Assert.Null(refreshed);
|
||||
}
|
||||
@@ -979,33 +979,33 @@ public class Security012GroupLookupFailureTests
|
||||
public class AuthorizationPolicyTests
|
||||
{
|
||||
[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);
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[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);
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[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);
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[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);
|
||||
Assert.True(result);
|
||||
}
|
||||
@@ -1022,19 +1022,21 @@ public class AuthorizationPolicyTests
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Audit Log #23 — OperationalAudit + AuditExport policies (M7-T15).
|
||||
// Default mapping (see AuthorizationPolicies XML doc):
|
||||
// Admin → OperationalAudit + AuditExport
|
||||
// Audit → OperationalAudit + AuditExport
|
||||
// AuditReadOnly → OperationalAudit only
|
||||
// Design → neither
|
||||
// Deployment → neither
|
||||
// Audit Log #23 — OperationalAudit + AuditExport policies (M7-T15),
|
||||
// post Task 1.7 canonicalization + SoD collapse. Default mapping
|
||||
// (see AuthorizationPolicies XML doc):
|
||||
// Administrator → OperationalAudit + AuditExport
|
||||
// Viewer → OperationalAudit only (former AuditReadOnly home)
|
||||
// Designer → neither
|
||||
// Deployer → neither
|
||||
// The former distinct Audit/AuditReadOnly roles no longer exist:
|
||||
// Audit → collapsed into Administrator
|
||||
// AuditReadOnly → collapsed into Viewer
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Admin")]
|
||||
[InlineData("Audit")]
|
||||
[InlineData("AuditReadOnly")]
|
||||
[InlineData("Administrator")]
|
||||
[InlineData("Viewer")]
|
||||
public async Task OperationalAuditPolicy_GrantedRoles_Succeed(string role)
|
||||
{
|
||||
var principal = CreatePrincipal(new[] { role });
|
||||
@@ -1042,8 +1044,8 @@ public class AuthorizationPolicyTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Design")]
|
||||
[InlineData("Deployment")]
|
||||
[InlineData("Designer")]
|
||||
[InlineData("Deployer")]
|
||||
public async Task OperationalAuditPolicy_UngrantedRoles_Fail(string role)
|
||||
{
|
||||
var principal = CreatePrincipal(new[] { role });
|
||||
@@ -1051,8 +1053,7 @@ public class AuthorizationPolicyTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Admin")]
|
||||
[InlineData("Audit")]
|
||||
[InlineData("Administrator")]
|
||||
public async Task AuditExportPolicy_GrantedRoles_Succeed(string role)
|
||||
{
|
||||
var principal = CreatePrincipal(new[] { role });
|
||||
@@ -1060,18 +1061,38 @@ public class AuthorizationPolicyTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("AuditReadOnly")]
|
||||
[InlineData("Design")]
|
||||
[InlineData("Deployment")]
|
||||
[InlineData("Viewer")]
|
||||
[InlineData("Designer")]
|
||||
[InlineData("Deployer")]
|
||||
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 });
|
||||
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)
|
||||
{
|
||||
var claims = new List<Claim>();
|
||||
@@ -1253,7 +1274,7 @@ public class CanonicalClaimVocabularyTests
|
||||
var service = CreateService();
|
||||
var token = service.GenerateToken(
|
||||
"Jane Roe", "janer",
|
||||
new[] { Roles.Audit },
|
||||
new[] { Roles.Administrator },
|
||||
new[] { "7" });
|
||||
|
||||
var principal = service.ValidateToken(token);
|
||||
@@ -1263,28 +1284,30 @@ public class CanonicalClaimVocabularyTests
|
||||
// (MapInboundClaims=false / cleared outbound map guarantee this).
|
||||
Assert.Equal("Jane Roe", principal!.FindFirst(ZbClaimTypes.DisplayName)?.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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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.
|
||||
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);
|
||||
Assert.NotNull(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!));
|
||||
// AuditReadOnly is read-only — separate assertion that the role VALUE semantics
|
||||
// are untouched by the type migration.
|
||||
// Viewer (post Task 1.7 home of the former AuditReadOnly role) is read-only —
|
||||
// separate assertion that the read-not-export half-SoD survives the type
|
||||
// migration AND the role collapse.
|
||||
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.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, roPrincipal!));
|
||||
}
|
||||
@@ -1299,7 +1322,7 @@ public class CanonicalClaimVocabularyTests
|
||||
new(ClaimTypes.Name, "janer"),
|
||||
new(JwtTokenService.DisplayNameClaimType, "Jane Roe"),
|
||||
new(JwtTokenService.UsernameClaimType, "janer"),
|
||||
new(JwtTokenService.RoleClaimType, Roles.Admin),
|
||||
new(JwtTokenService.RoleClaimType, Roles.Administrator),
|
||||
new(JwtTokenService.SiteIdClaimType, "3"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(
|
||||
@@ -1315,7 +1338,7 @@ public class CanonicalClaimVocabularyTests
|
||||
Assert.Equal("janer", principal.Identity?.Name); // ClaimTypes.Name resolves Identity.Name
|
||||
|
||||
// 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.
|
||||
Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal));
|
||||
|
||||
Reference in New Issue
Block a user