Skip to content

Commit c9b2d40

Browse files
authored
feat(aspnetcore): warn at startup when the API is mapped with no auth (#227)
Auth inherits from the host (ADR D20) and the engine imposes no default — but a host that just calls MapNeoReports() with no authentication configured and without setting RequireAuthorization exposes the whole report-management surface (trigger runs, register reports, store source connection strings, mutate schedules, download artifacts) unauthenticated. That is a valid deployment only behind a trusted boundary. MapNeoReports now logs a single Warning at mapping time when RequireAuthorization is not set AND no IAuthenticationSchemeProvider is registered (i.e. the host called neither AddAuthentication nor RequireAuthorization), so the unauthenticated posture is a deliberate choice rather than a silent default. The default behaviour is unchanged (still no imposed auth), so this is non-breaking; the warning stays quiet whenever the host has any authentication configured. Tests cover all three cases: warns with no auth, silent with AddAuthentication, silent with RequireAuthorization=true.
1 parent f7a92be commit c9b2d40

2 files changed

Lines changed: 114 additions & 0 deletions

File tree

src/Integrations/NeoReports.AspNetCore/NeoReportsEndpointRouteBuilderExtensions.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Globalization;
22
using System.IO.Compression;
33
using System.Text.Json;
4+
using Microsoft.AspNetCore.Authentication;
45
using Microsoft.AspNetCore.Builder;
56
using Microsoft.AspNetCore.Http;
67
using Microsoft.AspNetCore.Mvc;
@@ -81,6 +82,23 @@ public static RouteGroupBuilder MapNeoReports(
8182
else
8283
group.RequireAuthorization(options.AuthorizationPolicy);
8384
}
85+
else if (endpoints.ServiceProvider.GetService<IAuthenticationSchemeProvider>() is null)
86+
{
87+
// Auth inherits from the host (ADR D20) — the engine does not impose a default. But when
88+
// no authentication is configured on the host at all AND RequireAuthorization was not set,
89+
// this management surface (trigger runs, register reports, store source connection
90+
// strings, mutate schedules, download artifacts) is reachable unauthenticated. That is a
91+
// valid deployment only behind a trusted boundary; warn once at startup so it is a
92+
// deliberate choice, not a silent default.
93+
endpoints.ServiceProvider.GetService<ILoggerFactory>()?
94+
.CreateLogger("NeoReports.AspNetCore")
95+
.LogWarning(
96+
"NeoReports endpoints mapped at '{Prefix}' with no authentication configured on the host and " +
97+
"NeoReportsEndpointOptions.RequireAuthorization not set — the report management API is reachable " +
98+
"unauthenticated. Configure the host's authentication/authorization (or set RequireAuthorization) " +
99+
"before exposing it beyond a trusted network.",
100+
prefix);
101+
}
84102

85103
// Compiling a report — Create and Validate below — must resolve IConfigSourceProvider
86104
// (and, for a Ref-based source, ISourceRegistry) through the app's ROOT provider, never
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
using System.Collections.Concurrent;
2+
using Microsoft.AspNetCore.Builder;
3+
using Microsoft.AspNetCore.Hosting;
4+
using Microsoft.AspNetCore.TestHost;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.Hosting;
7+
using Microsoft.Extensions.Logging;
8+
using NeoReports.AspNetCore;
9+
using NeoReports.Core.Building;
10+
using NeoReports.Core.DependencyInjection;
11+
using Shouldly;
12+
using Xunit;
13+
using static NeoReports.Core.Building.ReportColumns;
14+
using static NeoReports.Formats.Csv.Format;
15+
16+
namespace NeoReports.AspNetCore.IntegrationTests;
17+
18+
public class AuthWarningTests
19+
{
20+
private static async Task<IReadOnlyCollection<string>> WarningsFromMapping(
21+
Action<IServiceCollection>? extraServices, Action<NeoReportsEndpointOptions>? options)
22+
{
23+
var capture = new CapturingLoggerProvider();
24+
using var host = await new HostBuilder()
25+
.ConfigureWebHost(web =>
26+
{
27+
web.UseTestServer();
28+
web.ConfigureServices(services =>
29+
{
30+
services.AddRouting();
31+
services.AddLogging(b => b.AddProvider(capture));
32+
services.AddReport<Sale>("sales", b => b
33+
.From(new InMemorySource(rows: 1, pageSize: 10))
34+
.Column(v => v.Id, "ID")
35+
.To(Csv(o => o.Delimiter(';'))));
36+
extraServices?.Invoke(services);
37+
})
38+
.Configure(app =>
39+
{
40+
app.UseRouting();
41+
app.UseEndpoints(e => e.MapNeoReports("/api", options));
42+
});
43+
})
44+
.StartAsync();
45+
46+
return capture.Warnings;
47+
}
48+
49+
[Fact]
50+
public async Task Warns_when_mapped_without_auth_and_no_authentication_configured()
51+
{
52+
var warnings = await WarningsFromMapping(extraServices: null, options: null);
53+
warnings.ShouldContain(w => w.Contains("reachable", StringComparison.Ordinal) && w.Contains("/api", StringComparison.Ordinal));
54+
}
55+
56+
[Fact]
57+
public async Task Does_not_warn_when_the_host_has_authentication_configured()
58+
{
59+
var warnings = await WarningsFromMapping(
60+
extraServices: s => s.AddAuthentication(), options: null);
61+
warnings.ShouldNotContain(w => w.Contains("reachable unauthenticated", StringComparison.Ordinal));
62+
}
63+
64+
[Fact]
65+
public async Task Does_not_warn_when_authorization_is_required()
66+
{
67+
var warnings = await WarningsFromMapping(
68+
extraServices: s => s.AddAuthorizationBuilder(),
69+
options: o => o.RequireAuthorization = true);
70+
warnings.ShouldNotContain(w => w.Contains("reachable unauthenticated", StringComparison.Ordinal));
71+
}
72+
73+
private sealed class CapturingLoggerProvider : ILoggerProvider
74+
{
75+
public ConcurrentBag<string> Warnings { get; } = [];
76+
77+
public ILogger CreateLogger(string categoryName) => new CapturingLogger(Warnings);
78+
79+
public void Dispose() { }
80+
81+
private sealed class CapturingLogger(ConcurrentBag<string> warnings) : ILogger
82+
{
83+
public IDisposable? BeginScope<TState>(TState state)
84+
where TState : notnull => null;
85+
86+
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning;
87+
88+
public void Log<TState>(
89+
LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
90+
{
91+
if (logLevel >= LogLevel.Warning)
92+
warnings.Add(formatter(state, exception));
93+
}
94+
}
95+
}
96+
}

0 commit comments

Comments
 (0)