fix(auth+cli): fail-closed site-scope gate (row 98); case-insensitive audit filter values with a strict server-side check (row 99)
Register row 98 — the site-scope authorization gate was FAIL-OPEN on an
unresolvable target. The resolve-then-check shape was written as
`if (target != null) EnforceSiteScope(...)`, so a null resolve skipped the
check entirely and a site-scoped caller passed the gate for any id in the
system. Authorization now fails CLOSED: a target that cannot be PROVEN to sit
inside the caller's scope is treated as outside it.
The defect turned out to have THREE more instances than the register recorded.
Besides `EnforceSiteScopeForInstance` and `EnforceSiteScopeForIdentifier`, the
same inline shape sat in `HandleGetInstance`, `HandleGetSite` and
`HandleGetDataConnection` — read handlers that resolve their own target and so
never call the helpers. Those were the sharper half of the bug: they answered
success-with-null for a nonexistent id and Unauthorized for an out-of-scope
one, making the pair an id-enumeration oracle over the whole fleet. All five
now route through one shared `DenyUnresolvedTargetIfScoped` arm.
Two properties are load-bearing and pinned by tests:
- System-wide callers (Administrator, or a Deployer with no site grants) are
untouched — they return at the guards above the lookup and keep the
handler's ordinary "not found" / null answer.
- The denial is indistinguishable from an out-of-scope denial: same
ManagementUnauthorized shape, and a message that never states whether the
target exists.
Register row 99 — CLI `audit query --status delivered` failed client-side while
the server would have accepted it: `AcceptOnlyFromAmong` matches ordinally
against the capitalised literals. Same treatment row 88 gave `deploy status` —
the client-side gate is removed, the valid set moves into the option help text,
and the server becomes the single authoritative check.
That required ADDING the server check, not just relying on one: the shared
audit filter parser was LAX, silently dropping an unrecognised value and
leaving the dimension unconstrained — so removing the CLI gate alone would have
turned a loud client-side failure into a plausible, complete-looking page that
did not answer the question asked. New `ParseEnumListStrict` (case-insensitive,
rejects ordinals — `Enum.TryParse` accepts "3", and a filter is a name-based
contract) backs the ManagementService /api/audit/query + /api/audit/export
endpoints, which now return 400 BAD_REQUEST naming the valid set.
Other AcceptOnlyFromAmong sites surveyed in the audit group: `--channel`,
`--kind`, `--status` on BOTH `query` and `export` (6 uses) plus `export
--format` (rejected `--format CSV` though the server lowercases it) — all seven
fixed here. Deliberately left alone: `template folder reorder --direction`
(pure client-side literal, no server-side parse to defer to) and the global
`--format json|table` (CLI-local output selection, never sent to a server).
The CentralUI AuditLogPage drill-in parser and the CentralUI export endpoint it
feeds deliberately KEEP the lax parse — they read a query string a human may
have bookmarked, where a stale fragment must still render the page. Both
contracts now live side by side in AuditQueryParamParsers and are documented as
non-reconcilable.
Docs: Component-ManagementService.md gains a "Site-scope gate: fail-closed on
an unresolvable target" section and a strict-filter note on the audit
endpoints; Component-Security.md records the fail-closed shape under Deployer
site scoping; the CLI README marks the audit filter values case-insensitive and
states the no-silent-drop / no-ordinals contract.
Tests: 5 new ManagementActorTests fail before the row-98 fix (verified by
reverting ManagementActor.cs) and cover scoped+nonexistent -> denied,
system-wide+nonexistent -> unchanged (both Administrator and no-grant Deployer),
scoped+in-scope -> allowed, and the indistinguishability property. 21 CLI tests
fail before the row-99 fix. Four pre-existing tests that pinned the removed
client-side gate were rewritten to pin the new contract.
This commit is contained in:
@@ -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