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