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:
@@ -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