merge(agent-b): rows 98/99 — site-scope gate fail-closed on unresolvable targets (5 sites, not 2: the inline GetInstance/GetSite/GetDataConnection shapes were an id-enumeration oracle; all route through DenyUnresolvedTargetIfScoped, denial indistinguishable from out-of-scope); CLI audit query/export drop 7 case-sensitive AcceptOnlyFromAmong gates for help-text values + a new strict server-side ParseEnumListStrict (no silent drop, no ordinals)
This commit is contained in:
@@ -41,28 +41,30 @@ public static class AuditCommands
|
||||
// --channel/--kind/--status/--site are multi-valued: System.CommandLine accepts
|
||||
// both repeated tokens (--channel A --channel B) and, with
|
||||
// AllowMultipleArgumentsPerToken, a single token carrying several values
|
||||
// (--channel A B). AcceptOnlyFromAmong validates EACH supplied value.
|
||||
// (--channel A B).
|
||||
//
|
||||
// The valid sets are named in the help text rather than enforced with
|
||||
// AcceptOnlyFromAmong: that check is case-SENSITIVE, so `--status delivered`
|
||||
// failed client-side even though the server has always parsed these
|
||||
// case-insensitively (register row 99 — same treatment row 88 gave
|
||||
// `deploy status`). The server rejects an unrecognised value with a 400 naming
|
||||
// the same list and is the authoritative check; HTTP API callers never pass
|
||||
// through these options at all.
|
||||
var channelOption = new Option<string[]>("--channel")
|
||||
{
|
||||
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable",
|
||||
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound; case-insensitive); repeatable",
|
||||
AllowMultipleArgumentsPerToken = true,
|
||||
};
|
||||
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
|
||||
var kindOption = new Option<string[]>("--kind")
|
||||
{
|
||||
Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve); repeatable",
|
||||
Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve; case-insensitive); repeatable",
|
||||
AllowMultipleArgumentsPerToken = true,
|
||||
};
|
||||
kindOption.AcceptOnlyFromAmong(
|
||||
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend",
|
||||
"NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve");
|
||||
var statusOption = new Option<string[]>("--status")
|
||||
{
|
||||
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped); repeatable",
|
||||
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped; case-insensitive); repeatable",
|
||||
AllowMultipleArgumentsPerToken = true,
|
||||
};
|
||||
statusOption.AcceptOnlyFromAmong(
|
||||
"Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped");
|
||||
var siteOption = new Option<string[]>("--site")
|
||||
{
|
||||
Description = "Filter by source site ID; repeatable",
|
||||
@@ -143,34 +145,36 @@ public static class AuditCommands
|
||||
{
|
||||
var sinceOption = new Option<string>("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601", Required = true };
|
||||
var untilOption = new Option<string>("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601", Required = true };
|
||||
var formatExportOption = new Option<string>("--format") { Description = "Export format", Required = true };
|
||||
formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet");
|
||||
// Same rationale as --channel/--kind/--status below and on `query` (register
|
||||
// row 99): AcceptOnlyFromAmong is case-SENSITIVE, so it rejected `--format CSV`
|
||||
// even though the server lowercases the value before matching. The server
|
||||
// returns a 400 naming csv/jsonl/parquet and is the authoritative check.
|
||||
var formatExportOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Export format (csv, jsonl, parquet; case-insensitive)",
|
||||
Required = true,
|
||||
};
|
||||
var outputOption = new Option<string>("--output") { Description = "Destination file path", Required = true };
|
||||
// --channel/--kind/--status/--site are multi-valued — same shape as the
|
||||
// `query` subcommand: repeated tokens (--channel A --channel B) and, with
|
||||
// AllowMultipleArgumentsPerToken, a single token carrying several values
|
||||
// (--channel A B). AcceptOnlyFromAmong validates EACH supplied value.
|
||||
// (--channel A B). Valid sets are named in the help text and enforced
|
||||
// server-side, case-insensitively — see the `query` builder for the full note.
|
||||
var channelOption = new Option<string[]>("--channel")
|
||||
{
|
||||
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable",
|
||||
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound; case-insensitive); repeatable",
|
||||
AllowMultipleArgumentsPerToken = true,
|
||||
};
|
||||
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
|
||||
var kindOption = new Option<string[]>("--kind")
|
||||
{
|
||||
Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve); repeatable",
|
||||
Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve; case-insensitive); repeatable",
|
||||
AllowMultipleArgumentsPerToken = true,
|
||||
};
|
||||
kindOption.AcceptOnlyFromAmong(
|
||||
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend",
|
||||
"NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve");
|
||||
var statusOption = new Option<string[]>("--status")
|
||||
{
|
||||
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped); repeatable",
|
||||
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped; case-insensitive); repeatable",
|
||||
AllowMultipleArgumentsPerToken = true,
|
||||
};
|
||||
statusOption.AcceptOnlyFromAmong(
|
||||
"Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped");
|
||||
var siteOption = new Option<string[]>("--site")
|
||||
{
|
||||
Description = "Filter by source site ID; repeatable",
|
||||
|
||||
@@ -1688,9 +1688,9 @@ scadabridge --url <url> audit query [options]
|
||||
|--------|----------|---------|-------------|
|
||||
| `--since` | no | — | Start time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
|
||||
| `--until` | no | — | End time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
|
||||
| `--channel` | no | — | Filter by channel (`ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`); repeatable — multiple values are OR-combined |
|
||||
| `--kind` | no | — | Filter by event kind (`ApiCall`, `ApiCallCached`, `DbWrite`, `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`, `CachedSubmit`, `CachedResolve`); repeatable — multiple values are OR-combined |
|
||||
| `--status` | no | — | Filter by status (`Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`); repeatable — multiple values are OR-combined |
|
||||
| `--channel` | no | — | Filter by channel (`ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`; case-insensitive); repeatable — multiple values are OR-combined |
|
||||
| `--kind` | no | — | Filter by event kind (`ApiCall`, `ApiCallCached`, `DbWrite`, `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`, `CachedSubmit`, `CachedResolve`; case-insensitive); repeatable — multiple values are OR-combined |
|
||||
| `--status` | no | — | Filter by status (`Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`; case-insensitive); repeatable — multiple values are OR-combined |
|
||||
| `--site` | no | — | Filter by source site ID; repeatable — multiple values are OR-combined |
|
||||
| `--target` | no | — | Filter by target (external system, DB connection, notification list) |
|
||||
| `--actor` | no | — | Filter by actor |
|
||||
@@ -1707,6 +1707,15 @@ either as repeated flags (`--channel ApiOutbound --channel DbOutbound`) or
|
||||
space-separated after one flag (`--channel ApiOutbound DbOutbound`). Values
|
||||
within one filter are OR-combined; the different filters are AND-combined.
|
||||
|
||||
**Filter values are case-insensitive** — `--status delivered`, `--status Delivered`
|
||||
and `--status DELIVERED` are the same query. The CLI does not validate the
|
||||
vocabulary itself; the server does, and an unrecognised value is an **error naming
|
||||
the valid set**, not a silently-dropped filter. That matters: dropping the filter
|
||||
would return a plausible, complete-looking page that does not answer the question
|
||||
asked (the same failure mode as `deploy status`). Ordinals are rejected — a filter
|
||||
is a name-based contract, so `--status 3` is an error even though `3` is a real
|
||||
enum value.
|
||||
|
||||
With `--format table`, events render as an aligned text table with columns
|
||||
`OccurredAtUtc`, `Channel`, `Kind`, `Status`, `Target`, `Actor`, `DurationMs`,
|
||||
`HttpStatus`; long `Target`/`Actor` values are truncated with an ellipsis. With
|
||||
@@ -1726,11 +1735,11 @@ scadabridge --url <url> audit export --since <time> --until <time> --format <fmt
|
||||
|--------|----------|---------|-------------|
|
||||
| `--since` | yes | — | Start time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
|
||||
| `--until` | yes | — | End time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
|
||||
| `--format` | yes | — | Export format: `csv`, `jsonl`, or `parquet` |
|
||||
| `--format` | yes | — | Export format: `csv`, `jsonl`, or `parquet` (case-insensitive) |
|
||||
| `--output` | yes | — | Destination file path |
|
||||
| `--channel` | no | — | Filter by channel |
|
||||
| `--kind` | no | — | Filter by event kind |
|
||||
| `--status` | no | — | Filter by status |
|
||||
| `--channel` | no | — | Filter by channel (case-insensitive; same valid set as `audit query`) |
|
||||
| `--kind` | no | — | Filter by event kind (case-insensitive; same valid set as `audit query`) |
|
||||
| `--status` | no | — | Filter by status (case-insensitive; same valid set as `audit query`) |
|
||||
| `--site` | no | — | Filter by source site ID |
|
||||
| `--target` | no | — | Filter by target |
|
||||
| `--actor` | no | — | Filter by actor |
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by <see cref="AuditQueryParamParsers.ParseEnumListStrict{TEnum}"/> when an
|
||||
/// Audit Log filter value is not a member name of its enum.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The message is composed to be shown VERBATIM to the caller — it names the offending
|
||||
/// value and the full valid set, and carries no server-internal detail — so the
|
||||
/// ManagementService audit endpoints surface it directly as the body of a
|
||||
/// <c>400 BAD_REQUEST</c> rather than replacing it with a generic fault string.
|
||||
/// </remarks>
|
||||
public class AuditFilterParseException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance with a caller-safe message naming the bad value
|
||||
/// and the valid set.
|
||||
/// </summary>
|
||||
/// <param name="message">The caller-safe error message.</param>
|
||||
public AuditFilterParseException(string message) : base(message) { }
|
||||
}
|
||||
@@ -21,11 +21,24 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Lax-parse contract.</b> Every value of a repeated parameter is parsed
|
||||
/// independently; an unparseable or blank element is silently dropped (NO 400)
|
||||
/// rather than failing the whole set. An empty result collapses to <c>null</c> so
|
||||
/// the corresponding filter dimension stays unconstrained.
|
||||
/// <b>Two parse contracts, chosen per surface.</b>
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Lax</b> (<see cref="ParseEnumList{TEnum}"/>): every value of a repeated
|
||||
/// parameter is parsed independently; an unparseable or blank element is silently
|
||||
/// dropped (NO 400) rather than failing the whole set. An empty result collapses to
|
||||
/// <c>null</c> so the corresponding filter dimension stays unconstrained. This is the
|
||||
/// right contract for the CentralUI <c>AuditLogPage</c> drill-in parser and the
|
||||
/// CentralUI export endpoint it feeds: those read a query string a human may have
|
||||
/// bookmarked or hand-edited, and a stale fragment must still render the page rather
|
||||
/// than fault it.</item>
|
||||
/// <item><b>Strict</b> (<see cref="ParseEnumListStrict{TEnum}"/>): an unrecognised
|
||||
/// value is an ERROR naming the valid set. This is the right contract for the
|
||||
/// ManagementService <c>/api/audit/query</c> + <c>/api/audit/export</c> endpoints,
|
||||
/// which answer a CLI/API caller who asked a specific question: silently dropping
|
||||
/// their filter returns a plausible, complete-looking result that does NOT answer it
|
||||
/// (register rows 46/88/99 — the same loud-failure principle).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static class AuditQueryParamParsers
|
||||
{
|
||||
@@ -57,6 +70,71 @@ public static class AuditQueryParamParsers
|
||||
return parsed.Count > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses each raw value as <typeparamref name="TEnum"/> (case-insensitive),
|
||||
/// REJECTING an unrecognised value with an
|
||||
/// <see cref="AuditFilterParseException"/> that names the valid set. Blank
|
||||
/// elements are still dropped silently (a repeated query param can legitimately
|
||||
/// carry an empty slot); a set that yields no value collapses to <c>null</c> so
|
||||
/// the filter dimension stays unconstrained.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Register row 99. Two traps this closes that a bare
|
||||
/// <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/> does not:
|
||||
/// <list type="number">
|
||||
/// <item><b>Numeric strings.</b> <c>TryParse</c> happily converts <c>"7"</c> —
|
||||
/// and <c>"3"</c>, which lands on a REAL member — into an enum value. A filter
|
||||
/// is a caller-facing name-based contract, never an ordinal one, so any value
|
||||
/// starting with a digit or sign is rejected outright. Enum member names can
|
||||
/// never start with those characters, so the check costs no legitimate input.</item>
|
||||
/// <item><b>Undefined composites.</b> <see cref="Enum.IsDefined{TEnum}(TEnum)"/>
|
||||
/// catches anything that parsed to a value with no declared member.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
/// <typeparam name="TEnum">The enum type to parse each raw value into.</typeparam>
|
||||
/// <param name="rawValues">Raw query-parameter string values to parse; may be null.</param>
|
||||
/// <param name="parameterName">Query-parameter name, used in the error message (e.g. <c>"status"</c>).</param>
|
||||
/// <returns>A non-empty list of parsed values, or <c>null</c> if no values were supplied.</returns>
|
||||
/// <exception cref="AuditFilterParseException">A supplied value is not a member name of <typeparamref name="TEnum"/>.</exception>
|
||||
public static IReadOnlyList<TEnum>? ParseEnumListStrict<TEnum>(
|
||||
IEnumerable<string?>? rawValues, string parameterName)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (rawValues is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parsed = new List<TEnum>();
|
||||
foreach (var raw in rawValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = raw.Trim();
|
||||
if (LooksNumeric(trimmed)
|
||||
|| !Enum.TryParse<TEnum>(trimmed, ignoreCase: true, out var value)
|
||||
|| !Enum.IsDefined(value))
|
||||
{
|
||||
throw new AuditFilterParseException(
|
||||
$"Unknown {parameterName} '{trimmed}'. Valid values: {string.Join(", ", Enum.GetNames<TEnum>())}.");
|
||||
}
|
||||
|
||||
parsed.Add(value);
|
||||
}
|
||||
|
||||
return parsed.Count > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the value opens with a digit or a sign — i.e. an ordinal, not an
|
||||
/// enum member name. No enum member name can start with these characters.
|
||||
/// </summary>
|
||||
private static bool LooksNumeric(string value)
|
||||
=> value.Length > 0 && (char.IsAsciiDigit(value[0]) || value[0] == '+' || value[0] == '-');
|
||||
|
||||
/// <summary>
|
||||
/// Trims each raw value and drops blank entries. Returns <c>null</c> when
|
||||
/// <paramref name="rawValues"/> is <c>null</c>, empty, or every value was
|
||||
|
||||
@@ -133,7 +133,16 @@ public static class AuditEndpoints
|
||||
return Forbidden("OperationalAudit");
|
||||
}
|
||||
|
||||
var filter = ParseFilter(context.Request.Query);
|
||||
AuditLogQueryFilter filter;
|
||||
try
|
||||
{
|
||||
filter = ParseFilter(context.Request.Query);
|
||||
}
|
||||
catch (AuditFilterParseException ex)
|
||||
{
|
||||
return BadFilter(ex);
|
||||
}
|
||||
|
||||
var restricted = ApplySiteScope(filter, auth.User!);
|
||||
if (restricted is null)
|
||||
{
|
||||
@@ -216,7 +225,16 @@ public static class AuditEndpoints
|
||||
statusCode: 400);
|
||||
}
|
||||
|
||||
var filter = ParseFilter(context.Request.Query);
|
||||
AuditLogQueryFilter filter;
|
||||
try
|
||||
{
|
||||
filter = ParseFilter(context.Request.Query);
|
||||
}
|
||||
catch (AuditFilterParseException ex)
|
||||
{
|
||||
return BadFilter(ex);
|
||||
}
|
||||
|
||||
var restricted = ApplySiteScope(filter, auth.User!);
|
||||
if (restricted is null)
|
||||
{
|
||||
@@ -532,6 +550,21 @@ public static class AuditEndpoints
|
||||
private static IResult Forbidden(string permission) => Results.Json(
|
||||
new { error = $"Permission '{permission}' required.", code = "UNAUTHORIZED" }, statusCode: 403);
|
||||
|
||||
/// <summary>
|
||||
/// Maps an <see cref="AuditFilterParseException"/> to the endpoint's existing
|
||||
/// <c>400 BAD_REQUEST</c> shape — the same envelope the unsupported-export-format
|
||||
/// branch already returns, so the CLI needs no new error handling.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The exception message is surfaced VERBATIM. It is composed by the parser to be
|
||||
/// caller-safe (offending value + the full valid set) and naming the valid set is
|
||||
/// the entire point of the check (register row 99).
|
||||
/// </remarks>
|
||||
/// <param name="ex">The parse failure to surface.</param>
|
||||
/// <returns>A 400 result carrying the caller-safe message.</returns>
|
||||
private static IResult BadFilter(AuditFilterParseException ex) => Results.Json(
|
||||
new { error = ex.Message, code = "BAD_REQUEST" }, statusCode: 400);
|
||||
|
||||
/// <summary>
|
||||
/// Applies the caller's <see cref="AuthenticatedUser.PermittedSiteIds"/> to the
|
||||
/// audit-log filter. System-wide callers (empty PermittedSiteIds —
|
||||
@@ -587,23 +620,42 @@ public static class AuditEndpoints
|
||||
/// <c>channel</c>/<c>kind</c>/<c>status</c>/<c>sourceSiteId</c> dimensions are
|
||||
/// multi-value: a repeated query param (<c>channel=A&channel=B</c>) yields
|
||||
/// a multi-element filter list, while a single param yields a one-element
|
||||
/// list. Unknown enum names / un-parseable Guids / dates are silently dropped
|
||||
/// (no 400) — the same lax contract the CentralUI export endpoint uses; an
|
||||
/// unparseable value within a repeated set is dropped, not the whole set.
|
||||
/// list.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The enum filters parse STRICTLY (register row 99); the Guid/date filters
|
||||
/// stay lax.</b> An unrecognised <c>channel</c>/<c>kind</c>/<c>status</c> throws
|
||||
/// <see cref="AuditFilterParseException"/>, which the two handlers turn into a
|
||||
/// <c>400 BAD_REQUEST</c> naming the valid set. Silently dropping a filter here
|
||||
/// used to hand the CLI a plausible, complete-looking page that did not answer
|
||||
/// the question asked — the same failure row 88 fixed for <c>deploy status</c>.
|
||||
/// The CLI reaches this endpoint with case-insensitive values now that its own
|
||||
/// case-SENSITIVE <c>AcceptOnlyFromAmong</c> guard is gone, so this is the
|
||||
/// authoritative check for those three dimensions.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The CentralUI <c>AuditLogPage</c> drill-in parser and the CentralUI export
|
||||
/// endpoint it feeds deliberately keep the LAX
|
||||
/// <see cref="AuditQueryParamParsers.ParseEnumList{TEnum}"/> — they read a query
|
||||
/// string a human may have bookmarked or hand-edited, where a stale fragment
|
||||
/// must still render the page. Do not "reconcile" the two.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This endpoint reads the source-site filter from the <c>sourceSiteId</c>
|
||||
/// query key, whereas the CentralUI export endpoint reads it as <c>site</c>.
|
||||
/// The divergence is deliberate — each endpoint matches its own CLI / UI URL
|
||||
/// builder — so do NOT "fix" the two to a single key name.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="query">The HTTP query string collection to parse filter parameters from.</param>
|
||||
/// <returns>An <see cref="AuditLogQueryFilter"/> built from the supplied query parameters.</returns>
|
||||
/// <exception cref="AuditFilterParseException">A channel/kind/status value is not a recognised member name.</exception>
|
||||
public static AuditLogQueryFilter ParseFilter(IQueryCollection query)
|
||||
{
|
||||
var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
|
||||
var kinds = AuditQueryParamParsers.ParseEnumList<AuditKind>(query["kind"]);
|
||||
var statuses = AuditQueryParamParsers.ParseEnumList<AuditStatus>(query["status"]);
|
||||
var channels = AuditQueryParamParsers.ParseEnumListStrict<AuditChannel>(query["channel"], "channel");
|
||||
var kinds = AuditQueryParamParsers.ParseEnumListStrict<AuditKind>(query["kind"], "kind");
|
||||
var statuses = AuditQueryParamParsers.ParseEnumListStrict<AuditStatus>(query["status"], "status");
|
||||
var sourceSiteIds = AuditQueryParamParsers.ParseStringList(query["sourceSiteId"]);
|
||||
|
||||
Guid? correlationId = null;
|
||||
|
||||
@@ -536,8 +536,49 @@ public class ManagementActor : ReceiveActor
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The fail-CLOSED arm of the scope gate: a target that could NOT be resolved is
|
||||
/// denied for a site-scoped caller, and ignored for a system-wide one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Register row 98. The scope gate used to be fail-OPEN on an unresolvable id —
|
||||
/// the resolve-then-check shape was written as <c>if (x != null) EnforceSiteScope(…)</c>,
|
||||
/// so a null resolve skipped the check entirely and a site-scoped caller passed the
|
||||
/// gate. Authorization now fails CLOSED: a target that cannot be PROVEN to sit
|
||||
/// inside the caller's scope is treated as outside it.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>System-wide callers are untouched.</b> Administrator, or a Deployer with no
|
||||
/// site grants (the rule documented on <see cref="EnforceSiteScope"/>), return at
|
||||
/// the guards below — they were never scoped, so they still reach the handler and
|
||||
/// still get its ordinary "not found"/null answer for a missing id.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The denial is deliberately indistinguishable from an out-of-scope one.</b>
|
||||
/// It raises the same <see cref="SiteScopeViolationException"/> (→
|
||||
/// <c>ManagementUnauthorized</c>), and <paramref name="targetDescription"/> must
|
||||
/// never state whether the target exists — otherwise the gate becomes an
|
||||
/// enumeration oracle letting a scoped caller probe ids across the whole fleet.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="user">The authenticated caller.</param>
|
||||
/// <param name="targetDescription">Caller-safe description of the target, e.g. <c>"instance 42"</c>.</param>
|
||||
/// <exception cref="SiteScopeViolationException">The caller is site-scoped.</exception>
|
||||
private static void DenyUnresolvedTargetIfScoped(AuthenticatedUser user, string targetDescription)
|
||||
{
|
||||
if (user.PermittedSiteIds.Length == 0) return;
|
||||
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return;
|
||||
|
||||
throw new SiteScopeViolationException(
|
||||
$"Access denied: {targetDescription} is not within your permitted sites " +
|
||||
$"[{string.Join(", ", user.PermittedSiteIds)}].");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the site ID for an instance and enforces site-scope.
|
||||
/// An instance that cannot be resolved is DENIED for a site-scoped caller
|
||||
/// (see <see cref="DenyUnresolvedTargetIfScoped"/>).
|
||||
/// </summary>
|
||||
private static async Task EnforceSiteScopeForInstance(IServiceProvider sp, AuthenticatedUser user, int instanceId)
|
||||
{
|
||||
@@ -546,13 +587,20 @@ public class ManagementActor : ReceiveActor
|
||||
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
var instance = await repo.GetInstanceByIdAsync(instanceId);
|
||||
if (instance != null)
|
||||
EnforceSiteScope(user, instance.SiteId);
|
||||
if (instance is null)
|
||||
{
|
||||
DenyUnresolvedTargetIfScoped(user, $"instance {instanceId}");
|
||||
return;
|
||||
}
|
||||
|
||||
EnforceSiteScope(user, instance.SiteId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a site by its string identifier and enforces site-scope.
|
||||
/// Used by remote-query handlers that key off the site identifier rather than its ID.
|
||||
/// A site identifier that cannot be resolved is DENIED for a site-scoped caller
|
||||
/// (see <see cref="DenyUnresolvedTargetIfScoped"/>).
|
||||
/// </summary>
|
||||
private static async Task EnforceSiteScopeForIdentifier(IServiceProvider sp, AuthenticatedUser user, string siteIdentifier)
|
||||
{
|
||||
@@ -561,8 +609,13 @@ public class ManagementActor : ReceiveActor
|
||||
|
||||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||||
var site = await repo.GetSiteByIdentifierAsync(siteIdentifier);
|
||||
if (site != null)
|
||||
EnforceSiteScope(user, site.Id);
|
||||
if (site is null)
|
||||
{
|
||||
DenyUnresolvedTargetIfScoped(user, $"site '{siteIdentifier}'");
|
||||
return;
|
||||
}
|
||||
|
||||
EnforceSiteScope(user, site.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -836,12 +889,28 @@ public class ManagementActor : ReceiveActor
|
||||
return Page(instances, cmd.Skip, cmd.Take);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads one instance, scope-checked against its own site.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This handler resolves the instance ITSELF rather than calling
|
||||
/// <see cref="EnforceSiteScopeForInstance"/> — the helper would repeat the same
|
||||
/// repository read — so it carries its own copy of the fail-CLOSED arm
|
||||
/// (register row 98). A scoped caller must not be able to tell a nonexistent
|
||||
/// instance (which used to answer success/null) from one at a site they may not
|
||||
/// see, or the read becomes an id-enumeration oracle over the whole fleet.
|
||||
/// </remarks>
|
||||
private static async Task<object?> HandleGetInstance(IServiceProvider sp, GetInstanceCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
var instance = await repo.GetInstanceByIdAsync(cmd.InstanceId);
|
||||
if (instance != null)
|
||||
EnforceSiteScope(user, instance.SiteId);
|
||||
if (instance is null)
|
||||
{
|
||||
DenyUnresolvedTargetIfScoped(user, $"instance {cmd.InstanceId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
EnforceSiteScope(user, instance.SiteId);
|
||||
return instance;
|
||||
}
|
||||
|
||||
@@ -1659,12 +1728,22 @@ public class ManagementActor : ReceiveActor
|
||||
return sites;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads one site, scope-checked against itself. Fail-CLOSED on an unresolvable
|
||||
/// id for a scoped caller — see <see cref="DenyUnresolvedTargetIfScoped"/>
|
||||
/// (register row 98).
|
||||
/// </summary>
|
||||
private static async Task<object?> HandleGetSite(IServiceProvider sp, GetSiteCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||||
var site = await repo.GetSiteByIdAsync(cmd.SiteId);
|
||||
if (site != null)
|
||||
EnforceSiteScope(user, site.Id);
|
||||
if (site is null)
|
||||
{
|
||||
DenyUnresolvedTargetIfScoped(user, $"site {cmd.SiteId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
EnforceSiteScope(user, site.Id);
|
||||
return site;
|
||||
}
|
||||
|
||||
@@ -1801,13 +1880,23 @@ public class ManagementActor : ReceiveActor
|
||||
return (await repo.GetAllDataConnectionsAsync()).Select(DataConnectionPublicShape).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads one data connection, scope-checked against its owning site. Fail-CLOSED
|
||||
/// on an unresolvable id for a scoped caller — see
|
||||
/// <see cref="DenyUnresolvedTargetIfScoped"/> (register row 98).
|
||||
/// </summary>
|
||||
private static async Task<object?> HandleGetDataConnection(IServiceProvider sp, GetDataConnectionCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||||
var conn = await repo.GetDataConnectionByIdAsync(cmd.DataConnectionId);
|
||||
if (conn != null)
|
||||
EnforceSiteScope(user, conn.SiteId);
|
||||
return conn is null ? null : DataConnectionPublicShape(conn);
|
||||
if (conn is null)
|
||||
{
|
||||
DenyUnresolvedTargetIfScoped(user, $"data connection {cmd.DataConnectionId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
EnforceSiteScope(user, conn.SiteId);
|
||||
return DataConnectionPublicShape(conn);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleCreateDataConnection(IServiceProvider sp, CreateDataConnectionCommand cmd, string user)
|
||||
|
||||
Reference in New Issue
Block a user