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:
@@ -416,23 +416,80 @@ public class AuditEndpointsTests
|
||||
Assert.Null(filter.SourceSiteIds);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Register row 99: the enum filter dimensions parse STRICTLY here.
|
||||
//
|
||||
// These used to assert the LAX contract — an unrecognised enum name was
|
||||
// dropped and the query ran unfiltered on that dimension, so `--status
|
||||
// Nonsense` returned a plausible, complete-looking page that did not answer
|
||||
// the question asked. That is exactly the failure row 88 fixed for `deploy
|
||||
// status`, and it became reachable from the CLI once the (case-SENSITIVE,
|
||||
// and therefore over-strict) client-side AcceptOnlyFromAmong guard was
|
||||
// removed. The Guid/date dimensions stay lax — see ParseFilter's remarks.
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ParseFilter_UnparseableValuesInRepeatedSet_AreDroppedSilently()
|
||||
public void ParseFilter_UnrecognisedEnumValue_Throws()
|
||||
{
|
||||
// Lax-parse contract: an unrecognised enum name is dropped, the rest of
|
||||
// the repeated set survives — no 400, no whole-set drop.
|
||||
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["channel"] = new[] { "ApiOutbound", "Bogus", "Notification" },
|
||||
});
|
||||
|
||||
var ex = Assert.Throws<AuditFilterParseException>(() => AuditEndpoints.ParseFilter(query));
|
||||
Assert.Contains("Bogus", ex.Message);
|
||||
Assert.Contains("channel", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFilter_UnrecognisedStatus_ThrowsNamingTheValidSet()
|
||||
{
|
||||
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["status"] = new[] { "Nonsense" },
|
||||
});
|
||||
|
||||
var ex = Assert.Throws<AuditFilterParseException>(() => AuditEndpoints.ParseFilter(query));
|
||||
foreach (var name in Enum.GetNames<AuditStatus>())
|
||||
{
|
||||
Assert.Contains(name, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFilter_LowercaseEnumValues_AreAccepted()
|
||||
{
|
||||
// The CLI sends whatever the operator typed now that its own case-sensitive
|
||||
// guard is gone; `--status delivered` must resolve here.
|
||||
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["channel"] = new[] { "apioutbound" },
|
||||
["kind"] = new[] { "apicall" },
|
||||
["status"] = new[] { "delivered", "PARKED" },
|
||||
});
|
||||
|
||||
var filter = AuditEndpoints.ParseFilter(query);
|
||||
|
||||
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }, filter.Channels);
|
||||
// Every value unparseable → the dimension stays unconstrained (null).
|
||||
Assert.Null(filter.Statuses);
|
||||
Assert.Equal(new[] { AuditChannel.ApiOutbound }, filter.Channels);
|
||||
Assert.Equal(new[] { AuditKind.ApiCall }, filter.Kinds);
|
||||
Assert.Equal(new[] { AuditStatus.Delivered, AuditStatus.Parked }, filter.Statuses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFilter_NumericEnumValue_IsRejected()
|
||||
{
|
||||
// Enum.TryParse would accept "3" as an ordinal; a filter is a name-based
|
||||
// contract, so it must not.
|
||||
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["status"] = new[] { "3" },
|
||||
});
|
||||
|
||||
Assert.Throws<AuditFilterParseException>(() => AuditEndpoints.ParseFilter(query));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -985,6 +985,193 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Register row 98: the scope gate fails CLOSED on an unresolvable target.
|
||||
//
|
||||
// EnforceSiteScopeForInstance / EnforceSiteScopeForIdentifier used to skip
|
||||
// EnforceSiteScope entirely when the id did not resolve, so a site-scoped
|
||||
// caller PASSED the gate and fell through into the handler body. A gate that
|
||||
// cannot prove the target is inside the caller's scope must treat it as
|
||||
// outside. System-wide callers return at the guards above the lookup and are
|
||||
// untouched — they still get the handler's ordinary "not found".
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void GetInstance_NonexistentIdForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
// _templateRepo is unstubbed for id 999 → GetInstanceByIdAsync returns null.
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetInstanceCommand(999), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
// The denial must not double as an existence oracle: the message says the
|
||||
// instance is not in scope, never whether it exists.
|
||||
Assert.DoesNotContain("not found", response.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("exist", response.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInstance_NonexistentIdForSiteScopedUser_IsIndistinguishableFromOutOfScope()
|
||||
{
|
||||
// The security property: a scoped caller cannot tell "instance 7 lives at a
|
||||
// site you may not see" apart from "instance 999 does not exist" — both are
|
||||
// the same ManagementUnauthorized shape.
|
||||
_templateRepo.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>())
|
||||
.Returns(new Instance("Pump7") { Id = 7, SiteId = 2 });
|
||||
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer"));
|
||||
var outOfScope = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
|
||||
actor.Tell(ScopedEnvelope(new GetInstanceCommand(999), new[] { "1" }, "Deployer"));
|
||||
var nonexistent = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(outOfScope.GetType(), nonexistent.GetType());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInstance_NonexistentIdForAdminUser_IsUnchanged()
|
||||
{
|
||||
// An Administrator is system-wide, so the gate returns before the lookup:
|
||||
// the handler runs and answers for itself. NOT a ManagementUnauthorized.
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetInstanceCommand(999), new[] { "1" }, "Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<object>(TimeSpan.FromSeconds(5));
|
||||
Assert.IsNotType<ManagementUnauthorized>(response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInstance_NonexistentIdForSystemWideDeployer_IsUnchanged()
|
||||
{
|
||||
// The other system-wide arm: a Deployer with NO site grants. Same rule,
|
||||
// same untouched behaviour.
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetInstanceCommand(999), Array.Empty<string>(), "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<object>(TimeSpan.FromSeconds(5));
|
||||
Assert.IsNotType<ManagementUnauthorized>(response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInstance_InScopeIdForSiteScopedUser_IsStillAllowed()
|
||||
{
|
||||
// The fail-closed change must not deny a legitimate in-scope target.
|
||||
_templateRepo.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>())
|
||||
.Returns(new Instance("Pump7") { Id = 7, SiteId = 1 });
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSite_NonexistentIdForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
// HandleGetSite resolves the site itself and carried its own copy of the
|
||||
// fail-OPEN shape (`if (site != null) EnforceSiteScope(...)`), so a scoped
|
||||
// caller used to get success/null for any id — an enumeration oracle.
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetSiteByIdAsync(999, Arg.Any<CancellationToken>())
|
||||
.Returns((Commons.Entities.Sites.Site?)null);
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetSiteCommand(999), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSite_NonexistentIdForAdminUser_IsUnchanged()
|
||||
{
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetSiteByIdAsync(999, Arg.Any<CancellationToken>())
|
||||
.Returns((Commons.Entities.Sites.Site?)null);
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetSiteCommand(999), new[] { "1" }, "Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<object>(TimeSpan.FromSeconds(5));
|
||||
Assert.IsNotType<ManagementUnauthorized>(response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDataConnection_NonexistentIdForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
// Third copy of the same inline fail-OPEN shape.
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetDataConnectionByIdAsync(999, Arg.Any<CancellationToken>())
|
||||
.Returns((Commons.Entities.Sites.DataConnection?)null);
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetDataConnectionCommand(999), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryEventLogs_NonexistentSiteIdentifierForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
// EnforceSiteScopeForIdentifier shares the shape: an identifier that
|
||||
// resolves to no Site row is a denial for a scoped caller.
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetSiteByIdentifierAsync("GHOST", Arg.Any<CancellationToken>())
|
||||
.Returns((Commons.Entities.Sites.Site?)null);
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(
|
||||
new QueryEventLogsCommand("GHOST"),
|
||||
new[] { "1" },
|
||||
"Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryEventLogs_InScopeSiteIdentifierForSiteScopedUser_IsStillAllowed()
|
||||
{
|
||||
AddSiteRepoWithSite(1, "SITE1");
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(
|
||||
new QueryEventLogsCommand("SITE1"),
|
||||
new[] { "1" },
|
||||
"Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
// The scope gate passes; whatever the (unwired) CommunicationService does
|
||||
// next is not this test's business — only that it is NOT a scope denial.
|
||||
var response = ExpectMsg<object>(TimeSpan.FromSeconds(5));
|
||||
Assert.IsNotType<ManagementUnauthorized>(response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSite_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user