fix(security): close auth & site-scoping gaps across 8 findings

Resolves the auth-theme batch from the 2026-05-28 baseline review (8 findings
across Security/CentralUI/ManagementService/CLI). The most consequential gaps:
NotificationReport + SiteCallsReport now route through SiteScopeService so a
site-scoped Deployment user cannot see or act on other sites' rows (CUI-028);
QueryAuditLogCommand is no longer "any authenticated user" — gated Admin-only
to match /api/audit/query's strictness (MS-018); RoleMapper preserves the
broader grant when a user is in both an unscoped and scoped Deployment LDAP
group, instead of silently narrowing to the scoped set (Sec-016); and the
dead SiteScopeRequirement/Handler are deleted so SiteScopeService is
unambiguously the sole site-scoping mechanism (Sec-017). Pending findings:
172 → 164.
This commit is contained in:
Joseph Doherty
2026-05-28 03:35:29 -04:00
parent f93b7b99bb
commit e536178323
28 changed files with 814 additions and 196 deletions
@@ -1,5 +1,6 @@
using System.Globalization;
using System.Net;
using System.Text.Json;
namespace ScadaLink.CLI.Commands;
@@ -147,10 +148,18 @@ public static class AuditExportHelpers
if (!response.IsSuccessStatusCode)
{
var message = await response.Content.ReadAsStringAsync();
// CLI-018: honour the documented "authorization failure → exit 2"
// contract on the REST audit surface as well. HTTP 403 is the
// primary signal; the server may also surface UNAUTHORIZED /
// FORBIDDEN via the JSON error envelope on a non-403 status.
var errorCode = TryExtractErrorCode(message);
var isAuthFailure = (int)response.StatusCode == 403
|| string.Equals(errorCode, "FORBIDDEN", StringComparison.OrdinalIgnoreCase)
|| string.Equals(errorCode, "UNAUTHORIZED", StringComparison.OrdinalIgnoreCase);
OutputFormatter.WriteError(
string.IsNullOrWhiteSpace(message) ? $"Export failed (HTTP {(int)response.StatusCode})." : message,
"ERROR");
return 1;
errorCode ?? "ERROR");
return isAuthFailure ? 2 : 1;
}
await using var source = await response.Content.ReadAsStreamAsync();
@@ -163,4 +172,32 @@ public static class AuditExportHelpers
output.WriteLine($"Exported audit log to {args.Output}");
return 0;
}
/// <summary>
/// Best-effort parse of the server's JSON error envelope (<c>{ "error": ..., "code": ... }</c>)
/// to extract the <c>code</c> field. Returns null if the body is empty, not valid JSON, or
/// has no <c>code</c> property — callers fall back to "ERROR" in that case.
/// </summary>
internal static string? TryExtractErrorCode(string body)
{
if (string.IsNullOrWhiteSpace(body))
return null;
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty("code", out var codeProp)
&& codeProp.ValueKind == JsonValueKind.String)
{
return codeProp.GetString();
}
}
catch (JsonException)
{
// Body is not a JSON envelope (e.g. an HTML proxy error page); no code to extract.
}
return null;
}
}
@@ -189,7 +189,9 @@ public static class AuditQueryHelpers
{
OutputFormatter.WriteError(
response.Error ?? "Audit query failed.", response.ErrorCode ?? "ERROR");
return 1;
// CLI-018: surface the documented "authorization failure → exit 2"
// contract for the audit REST surface too, not just /management.
return CommandHelpers.IsAuthorizationFailure(response) ? 2 : 1;
}
using var doc = JsonDocument.Parse(response.JsonData);
+1 -1
View File
@@ -164,7 +164,7 @@ internal static class CommandHelpers
/// both channels are honoured. (Authentication failure — HTTP 401 / bad credentials
/// — is deliberately <em>not</em> treated as authorization failure; it is exit 1.)
/// </summary>
private static bool IsAuthorizationFailure(ManagementResponse response)
internal static bool IsAuthorizationFailure(ManagementResponse response)
{
if (response.StatusCode == 403)
return true;
@@ -88,8 +88,9 @@ public sealed class SiteScopeService
ids.Add(id);
}
// No SiteId claims => system-wide. This mirrors SiteScopeAuthorizationHandler:
// absence of scope rules means an unrestricted deployer.
// No SiteId claims => system-wide. Absence of scope rules means an
// unrestricted deployer (Security-017 made this service the sole
// site-scoping mechanism — there is no separate handler to mirror).
var result = (IsSystemWide: ids.Count == 0, Sites: (IReadOnlySet<int>)ids);
_cached = result;
return result;
@@ -1,11 +1,13 @@
@page "/notifications/report"
@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)]
@using ScadaLink.CentralUI.Auth
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Messages.Notification
@using ScadaLink.Communication
@inject CommunicationService CommunicationService
@inject ISiteRepository SiteRepository
@inject SiteScopeService SiteScope
@inject IDialogService Dialog
@inject ILogger<NotificationReport> Logger
@@ -366,6 +368,12 @@
private ToastNotification _toast = default!;
private List<Site> _sites = new();
// CentralUI-028: full site list (kept unfiltered) so a permitted-site check
// resolves correctly for a SourceSiteId whose Site was filtered out of the
// dropdown. Set once in OnInitializedAsync alongside _sites.
private List<Site> _allSites = new();
private bool _siteScopeSystemWide;
private HashSet<int> _permittedSiteIds = new();
// List
private List<NotificationSummary>? _notifications;
@@ -396,7 +404,13 @@
{
try
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
_allSites = (await SiteRepository.GetAllSitesAsync()).ToList();
// CentralUI-028: restrict the site dropdown to the user's permitted set
// so a site-scoped Deployment user cannot select a site they have no
// grant for. System-wide users see the full list back unchanged.
_sites = await SiteScope.FilterSitesAsync(_allSites);
_siteScopeSystemWide = await SiteScope.IsSystemWideAsync();
_permittedSiteIds = new HashSet<int>(await SiteScope.PermittedSiteIdsAsync());
}
catch (Exception ex)
{
@@ -444,7 +458,12 @@
var response = await CommunicationService.QueryNotificationOutboxAsync(request);
if (response.Success)
{
_notifications = response.Notifications.ToList();
// CentralUI-028: drop any row whose source site is outside the
// user's permitted set. The query API accepts only a single
// SourceSiteFilter, so a scoped user with an empty filter could
// otherwise see every site's rows; this is the row-level safety
// net behind the dropdown restriction.
_notifications = await FilterPermittedAsync(response.Notifications);
_totalCount = response.TotalCount;
}
else
@@ -461,6 +480,15 @@
private async Task RetryNotification(NotificationSummary n)
{
// CentralUI-028: server-side re-check before relaying — even if the row
// somehow made it into the grid for an out-of-scope user (race with a
// permission change, stale circuit cache), the relay must not fire.
if (!await IsRowSiteAllowedAsync(n.SourceSiteId))
{
_toast.ShowError("You are not permitted to act on notifications for this site.");
return;
}
var confirmed = await Dialog.ConfirmAsync(
"Retry notification",
$"Re-queue notification {ShortId(n.NotificationId)} (\"{n.Subject}\") for delivery?");
@@ -490,6 +518,12 @@
private async Task DiscardNotification(NotificationSummary n)
{
if (!await IsRowSiteAllowedAsync(n.SourceSiteId))
{
_toast.ShowError("You are not permitted to act on notifications for this site.");
return;
}
var confirmed = await Dialog.ConfirmAsync(
"Discard notification",
$"Permanently discard notification {ShortId(n.NotificationId)} (\"{n.Subject}\")? This cannot be undone.",
@@ -650,4 +684,50 @@
"Discarded" => "bg-secondary",
_ => "bg-light text-dark"
};
/// <summary>
/// Drops any notification whose <c>SourceSiteId</c> resolves to a Site.Id outside
/// the caller's permitted set. A system-wide user gets the list back unchanged.
/// Lookup uses <c>_allSites</c> (NOT <c>_sites</c>) so a row whose Site was
/// filtered OUT of the dropdown is correctly classified as out-of-scope.
/// A truly unknown <c>SourceSiteId</c> (stale row from a deleted site) is kept —
/// there is no Site.Id to gate it on.
/// </summary>
private Task<List<NotificationSummary>> FilterPermittedAsync(
IEnumerable<NotificationSummary> notifications)
{
if (_siteScopeSystemWide)
return Task.FromResult(notifications.ToList());
var filtered = notifications
.Where(n =>
{
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == n.SourceSiteId);
return resolved is null || _permittedSiteIds.Contains(resolved.Id);
})
.ToList();
return Task.FromResult(filtered);
}
/// <summary>
/// Server-side re-check for the Retry/Discard relay actions. Returns true for a
/// system-wide user, or when the row's source site resolves to a Site.Id in the
/// caller's permitted set. An unresolvable site identifier defaults to allowed
/// (legacy behaviour); the relay's own site-scope re-check is then the
/// final gate.
/// </summary>
private bool IsRowSiteAllowedSync(string sourceSiteId)
{
if (_siteScopeSystemWide)
return true;
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == sourceSiteId);
if (resolved is null)
return true;
return _permittedSiteIds.Contains(resolved.Id);
}
private Task<bool> IsRowSiteAllowedAsync(string sourceSiteId)
=> Task.FromResult(IsRowSiteAllowedSync(sourceSiteId));
}
@@ -1,5 +1,6 @@
@page "/site-calls/report"
@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)]
@using ScadaLink.CentralUI.Auth
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Messages.Audit
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using ScadaLink.CentralUI.Auth;
using ScadaLink.CentralUI.Components.Shared;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Messages.Audit;
@@ -44,6 +45,7 @@ public partial class SiteCallsReport
private const int PageSize = 50;
[Inject] private NavigationManager Navigation { get; set; } = null!;
[Inject] private SiteScopeService SiteScope { get; set; } = null!;
// The Status filter <select> options — the exact strings the dropdown binds and
// the KPI tiles emit (e.g. ?status=Parked). A query-string status only seeds the
@@ -56,6 +58,11 @@ public partial class SiteCallsReport
private ToastNotification _toast = default!;
private List<Site> _sites = new();
// CentralUI-028: unfiltered site list so a permitted-site lookup resolves
// correctly for a SourceSite whose Site was filtered out of the dropdown.
private List<Site> _allSites = new();
private bool _siteScopeSystemWide;
private HashSet<int> _permittedSiteIds = new();
// List
private List<SiteCallSummary>? _siteCalls;
@@ -94,7 +101,12 @@ public partial class SiteCallsReport
{
try
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
_allSites = (await SiteRepository.GetAllSitesAsync()).ToList();
// CentralUI-028: restrict the source-site dropdown to the user's
// permitted set. System-wide users see the full list back unchanged.
_sites = await SiteScope.FilterSitesAsync(_allSites);
_siteScopeSystemWide = await SiteScope.IsSystemWideAsync();
_permittedSiteIds = new HashSet<int>(await SiteScope.PermittedSiteIdsAsync());
}
catch (Exception ex)
{
@@ -212,7 +224,10 @@ public partial class SiteCallsReport
var response = await CommunicationService.QuerySiteCallsAsync(request);
if (response.Success)
{
_siteCalls = response.SiteCalls.ToList();
// CentralUI-028: drop any row whose source site is outside the
// user's permitted set, as a row-level safety net behind the
// dropdown restriction.
_siteCalls = await FilterPermittedAsync(response.SiteCalls);
_currentCursor = cursor;
// The response echoes the last row's cursor. A short page (fewer
@@ -238,6 +253,15 @@ public partial class SiteCallsReport
private async Task RetrySiteCall(SiteCallSummary c)
{
// CentralUI-028: server-side re-check before relaying — a Retry relay must
// not fire for a site outside the caller's permitted set, even if the row
// somehow appeared in the grid.
if (!await IsRowSiteAllowedAsync(c.SourceSite))
{
_toast.ShowError("You are not permitted to act on cached calls for this site.");
return;
}
var confirmed = await Dialog.ConfirmAsync(
"Retry cached call",
$"Relay a retry of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
@@ -265,6 +289,12 @@ public partial class SiteCallsReport
private async Task DiscardSiteCall(SiteCallSummary c)
{
if (!await IsRowSiteAllowedAsync(c.SourceSite))
{
_toast.ShowError("You are not permitted to act on cached calls for this site.");
return;
}
var confirmed = await Dialog.ConfirmAsync(
"Discard cached call",
$"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
@@ -448,4 +478,42 @@ public partial class SiteCallsReport
"Discarded" => "bg-secondary",
_ => "bg-light text-dark"
};
/// <summary>
/// Drops any site-call row whose source site resolves to a Site.Id outside the
/// caller's permitted set. System-wide users get the list back unchanged.
/// Lookup uses <c>_allSites</c> (not <c>_sites</c>) so a row whose Site was
/// filtered OUT of the dropdown is correctly classified as out-of-scope.
/// </summary>
private Task<List<SiteCallSummary>> FilterPermittedAsync(
IEnumerable<SiteCallSummary> calls)
{
if (_siteScopeSystemWide)
return Task.FromResult(calls.ToList());
var filtered = calls
.Where(c =>
{
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == c.SourceSite);
return resolved is null || _permittedSiteIds.Contains(resolved.Id);
})
.ToList();
return Task.FromResult(filtered);
}
/// <summary>
/// Server-side re-check for the Retry/Discard relay. True for a system-wide
/// user, or when the row's source site maps to a Site.Id in the permitted set.
/// </summary>
private Task<bool> IsRowSiteAllowedAsync(string sourceSite)
{
if (_siteScopeSystemWide)
return Task.FromResult(true);
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == sourceSite);
if (resolved is null)
return Task.FromResult(true);
return Task.FromResult(_permittedSiteIds.Contains(resolved.Id));
}
}
@@ -117,6 +117,12 @@ public static class AuditEndpoints
}
var filter = ParseFilter(context.Request.Query);
var restricted = ApplySiteScope(filter, auth.User!);
if (restricted is null)
{
return Forbidden("OperationalAudit");
}
filter = restricted;
var paging = ParsePaging(context.Request.Query);
var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
@@ -189,6 +195,12 @@ public static class AuditEndpoints
}
var filter = ParseFilter(context.Request.Query);
var restricted = ApplySiteScope(filter, auth.User!);
if (restricted is null)
{
return Forbidden("AuditExport");
}
filter = restricted;
var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
var contentType = format == "csv" ? "text/csv; charset=utf-8" : "application/x-ndjson";
@@ -374,6 +386,49 @@ public static class AuditEndpoints
private static IResult Forbidden(string permission) => Results.Json(
new { error = $"Permission '{permission}' required.", code = "UNAUTHORIZED" }, statusCode: 403);
/// <summary>
/// Applies the caller's <see cref="AuthenticatedUser.PermittedSiteIds"/> to the
/// audit-log filter (Management-019). System-wide callers (empty PermittedSiteIds —
/// Admin or a Deployment-style role with no scope rules attached to its mapping)
/// see the filter unchanged. A scoped caller has any caller-supplied
/// <c>sourceSiteId</c> intersected with their permitted set: an empty caller filter
/// is replaced by the permitted set; an explicit out-of-scope filter (no overlap
/// with the permitted set) returns <c>null</c> so the caller gets a 403 rather
/// than silently empty results.
/// </summary>
/// <returns>
/// The restricted filter, or <c>null</c> if the caller explicitly asked for
/// sites entirely outside their permitted set.
/// </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.
if (user.PermittedSiteIds.Length == 0)
{
return filter;
}
var permitted = new HashSet<string>(user.PermittedSiteIds, StringComparer.Ordinal);
if (filter.SourceSiteIds is null || filter.SourceSiteIds.Count == 0)
{
// No explicit filter — restrict to permitted set.
return filter with { SourceSiteIds = permitted.ToArray() };
}
// Explicit filter — intersect.
var intersection = filter.SourceSiteIds.Where(permitted.Contains).ToArray();
if (intersection.Length == 0)
{
return null;
}
return filter with { SourceSiteIds = intersection };
}
// ─────────────────────────────────────────────────────────────────────
// Query-string parsing
// ─────────────────────────────────────────────────────────────────────
@@ -158,7 +158,15 @@ public class ManagementActor : ReceiveActor
or UpdateRoleMappingCommand or DeleteRoleMappingCommand
or ListApiKeysCommand or CreateApiKeyCommand or DeleteApiKeyCommand
or UpdateApiKeyCommand
or ListScopeRulesCommand or AddScopeRuleCommand or DeleteScopeRuleCommand => "Admin",
or ListScopeRulesCommand or AddScopeRuleCommand or DeleteScopeRuleCommand
// QueryAuditLogCommand: legacy Action/EntityType filter on the
// configuration audit log. Gated Admin-only so this older path is
// never looser than the keyset-paged `/api/audit/query` endpoint
// (which requires OperationalAuditRoles). New audit consumers
// should use the REST endpoint; this command is retained for
// backward compatibility with the CentralUI Configuration Audit
// Log page (Management-018).
or QueryAuditLogCommand => "Admin",
// Design operations
CreateAreaCommand or DeleteAreaCommand
@@ -92,7 +92,7 @@ public static class AuthorizationPolicies
/// rather than the ASP.NET authorization-policy pipeline — can reuse the
/// exact same role set the <see cref="OperationalAudit"/> policy enforces.
/// </remarks>
public static readonly string[] OperationalAuditRoles = { "Admin", "Audit", "AuditReadOnly" };
public static readonly string[] OperationalAuditRoles = { Roles.Admin, Roles.Audit, Roles.AuditReadOnly };
/// <summary>
/// Roles that satisfy <see cref="AuditExport"/>. A strict subset of
@@ -104,7 +104,7 @@ public static class AuthorizationPolicies
/// the ManagementService <c>/api/audit/export</c> route checks roles
/// against this set directly.
/// </remarks>
public static readonly string[] AuditExportRoles = { "Admin", "Audit" };
public static readonly string[] AuditExportRoles = { Roles.Admin, Roles.Audit };
/// <summary>
/// Registers the ScadaLink authorization policies (Admin, Design, Deployment, OperationalAudit, AuditExport).
@@ -115,13 +115,13 @@ public static class AuthorizationPolicies
services.AddAuthorization(options =>
{
options.AddPolicy(RequireAdmin, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, "Admin"));
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Admin));
options.AddPolicy(RequireDesign, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, "Design"));
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Design));
options.AddPolicy(RequireDeployment, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, "Deployment"));
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Deployment));
// Multi-role permission policies — the policy succeeds when the
// principal holds ANY of the mapped roles. RequireClaim with
@@ -137,8 +137,6 @@ public static class AuthorizationPolicies
policy.RequireClaim(JwtTokenService.RoleClaimType, AuditExportRoles));
});
services.AddSingleton<IAuthorizationHandler, SiteScopeAuthorizationHandler>();
return services;
}
}
+44 -6
View File
@@ -81,11 +81,14 @@ public class LdapAuthService
var bindDn = await ResolveUserDnAsync(connection, username, ct);
await Task.Run(() => connection.Bind(bindDn, password), ct);
// Re-bind as service account for attribute/group lookup (user may lack search rights)
// Re-bind as service account for attribute/group lookup (user may lack search rights).
// A failure here is the SYSTEM's misconfiguration (wrong service-account credentials,
// disabled/locked account) — not the user's credential problem. The user bind on the
// line above already succeeded, so masking this as "Invalid username or password" would
// route operators down the wrong incident path (Security-019).
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
{
await Task.Run(() =>
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
await BindServiceAccountAsync(connection, ct);
}
// Query for user attributes and group memberships
@@ -144,6 +147,16 @@ public class LdapAuthService
return BuildAuthResultFromGroupLookup(username, displayName, groups, groupLookupSucceeded);
}
catch (ServiceAccountBindException ex)
{
// Distinct from the user-credential catch below so the operator
// sees the *system* misconfiguration rather than blaming user input
// (Security-019). The inner exception was already logged at Error
// by BindServiceAccountAsync; nothing further to log here.
_ = ex;
return new LdapAuthResult(false, null, username, null,
"Authentication service is misconfigured. Contact an administrator.");
}
catch (LdapException ex)
{
_logger.LogWarning(ex, "LDAP authentication failed for user {Username}", username);
@@ -156,6 +169,29 @@ public class LdapAuthService
}
}
/// <summary>
/// Binds the supplied connection as the configured service account. A failure here is
/// a system-misconfiguration condition (Security-019) — wrong service-account DN /
/// password, locked or disabled account, server-side ACL change — not a user-credential
/// problem. The underlying <see cref="LdapException"/> is logged at Error and rethrown
/// as <see cref="ServiceAccountBindException"/> so callers can distinguish it from a
/// user-bind failure.
/// </summary>
private async Task BindServiceAccountAsync(LdapConnection connection, CancellationToken ct)
{
try
{
await Task.Run(() =>
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
}
catch (LdapException ex)
{
_logger.LogError(ex,
"Service-account rebind failed; check LdapServiceAccountDn / LdapServiceAccountPassword configuration");
throw new ServiceAccountBindException(ex);
}
}
/// <summary>
/// Applies <see cref="SecurityOptions.LdapConnectionTimeoutMs"/> to both the socket
/// connect timeout and the per-operation (bind/search) time limit, so a hung or
@@ -183,11 +219,13 @@ public class LdapAuthService
/// </summary>
private async Task<string> ResolveUserDnAsync(LdapConnection connection, string username, CancellationToken ct)
{
// If a service account is configured, search for the user's actual DN
// If a service account is configured, search for the user's actual DN.
// The service-account bind is routed through BindServiceAccountAsync so a
// misconfiguration surfaces distinctly rather than masking as
// "Invalid username or password" (Security-019).
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
{
await Task.Run(() =>
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
await BindServiceAccountAsync(connection, ct);
var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})";
var searchResults = await Task.Run(() =>
+23 -6
View File
@@ -28,7 +28,8 @@ public class RoleMapper
var matchedRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var permittedSiteIds = new HashSet<string>();
var hasDeploymentRole = false;
var hasDeploymentWithScopeRules = false;
var hasScopedDeploymentMapping = false;
var hasUnscopedDeploymentMapping = false;
foreach (var mapping in allMappings)
{
@@ -38,25 +39,41 @@ public class RoleMapper
matchedRoles.Add(mapping.Role);
if (mapping.Role.Equals("Deployment", StringComparison.OrdinalIgnoreCase))
if (mapping.Role.Equals(Roles.Deployment, StringComparison.OrdinalIgnoreCase))
{
hasDeploymentRole = true;
// Check for site scope rules
var scopeRules = await _securityRepository.GetScopeRulesForMappingAsync(mapping.Id, ct);
if (scopeRules.Count > 0)
{
hasDeploymentWithScopeRules = true;
hasScopedDeploymentMapping = true;
foreach (var rule in scopeRules)
{
permittedSiteIds.Add(rule.SiteId.ToString());
}
}
else
{
hasUnscopedDeploymentMapping = true;
}
}
}
// System-wide deployment: user has Deployment role but no site scope rules restrict them
var isSystemWide = hasDeploymentRole && !hasDeploymentWithScopeRules;
// Union semantics (Security-016): a Deployment user is system-wide iff
// *any* matched Deployment mapping has no scope rules. A user in both
// SCADA-Deploy-All (unscoped) and SCADA-Deploy-SiteA (scoped to Site A)
// gets the broader grant, not the narrower one — matching the design's
// "roles are independent — there is no implied hierarchy" rule.
var isSystemWide = hasUnscopedDeploymentMapping
|| (hasDeploymentRole && !hasScopedDeploymentMapping);
// When system-wide, drop any accumulated scope ids — the empty
// permitted set is the system-wide signal downstream consumers
// (SiteScopeService, ManagementActor) already use.
if (isSystemWide)
{
permittedSiteIds.Clear();
}
return new RoleMappingResult(
matchedRoles.ToList(),
+22
View File
@@ -0,0 +1,22 @@
namespace ScadaLink.Security;
/// <summary>
/// Single source of truth for role-name string literals used across the
/// Security module and downstream authorization checks.
/// </summary>
/// <remarks>
/// 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.
/// </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";
}
@@ -0,0 +1,15 @@
namespace ScadaLink.Security;
/// <summary>
/// Thrown by <see cref="LdapAuthService"/> when the configured LDAP service-account
/// rebind fails. Distinct from a user-bind <c>LdapException</c> so the outer login
/// pipeline can surface "Authentication service is misconfigured" instead of
/// masking the system fault as "Invalid username or password" (Security-019).
/// </summary>
public sealed class ServiceAccountBindException : Exception
{
public ServiceAccountBindException(Exception innerException)
: base("LDAP service-account rebind failed", innerException)
{
}
}
@@ -1,58 +0,0 @@
using Microsoft.AspNetCore.Authorization;
namespace ScadaLink.Security;
/// <summary>
/// Authorization requirement for site-scoped deployment operations.
/// </summary>
public class SiteScopeRequirement : IAuthorizationRequirement
{
/// <summary>Gets the site id the deploying user must be permitted to operate on.</summary>
public string TargetSiteId { get; }
/// <summary>
/// Initializes a new <see cref="SiteScopeRequirement"/> for the given site.
/// </summary>
/// <param name="targetSiteId">The id of the site being targeted by the operation.</param>
public SiteScopeRequirement(string targetSiteId)
{
TargetSiteId = targetSiteId ?? throw new ArgumentNullException(nameof(targetSiteId));
}
}
/// <summary>
/// Checks that a user with the Deployment role is permitted to operate on the target site.
/// Users with Deployment role and no SiteId claims are system-wide deployers.
/// Users with SiteId claims are only permitted on those specific sites.
/// </summary>
public class SiteScopeAuthorizationHandler : AuthorizationHandler<SiteScopeRequirement>
{
/// <inheritdoc />
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
SiteScopeRequirement requirement)
{
// Must have Deployment role
var hasDeploymentRole = context.User.HasClaim(JwtTokenService.RoleClaimType, "Deployment");
if (!hasDeploymentRole)
{
return Task.CompletedTask; // Fail — no Deployment role
}
var siteIdClaims = context.User.FindAll(JwtTokenService.SiteIdClaimType).ToList();
if (siteIdClaims.Count == 0)
{
// No site scope restrictions — system-wide deployer
context.Succeed(requirement);
}
else if (siteIdClaims.Any(c => c.Value == requirement.TargetSiteId))
{
// User is permitted on this specific site
context.Succeed(requirement);
}
// Otherwise, silently fail (not authorized for this site)
return Task.CompletedTask;
}
}