Skip to content

Commit 01d07d7

Browse files
committed
Merge origin/main into fix/perf-14-dbcontext-resilience
Resolve conflict in OptionsValidationTests.cs by keeping both DatabaseSettings validation tests (feature branch) and AuditRetentionSettings validation tests (main).
2 parents 6764f5e + fe091a1 commit 01d07d7

69 files changed

Lines changed: 7933 additions & 1910 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
2828
services.AddScoped<AuthenticationService>();
2929
services.AddScoped<AuthorizationService>();
3030
services.AddScoped<MfaService>();
31+
services.AddSingleton<OAuthScopeValidator>();
3132
services.AddScoped<IAuthorizationService>(sp => sp.GetRequiredService<AuthorizationService>());
3233
services.AddScoped<UserService>();
3334
services.AddScoped<BoardAccessService>();

backend/src/Taskdeck.Api/Extensions/AuthenticationRegistration.cs

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
using Microsoft.AspNetCore.Authentication;
44
using Microsoft.AspNetCore.Authentication.Cookies;
55
using Microsoft.AspNetCore.Authentication.JwtBearer;
6+
using Microsoft.AspNetCore.Authentication.OAuth;
67
using Microsoft.AspNetCore.Authentication.OAuth.Claims;
78
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
89
using Microsoft.AspNetCore.Http;
10+
using Microsoft.Extensions.DependencyInjection;
911
using Microsoft.Extensions.Http;
12+
using Microsoft.Extensions.Logging;
1013
using Microsoft.IdentityModel.Tokens;
1114
using Polly;
1215
using Polly.Extensions.Http;
@@ -135,15 +138,69 @@ await context.Response.WriteAsJsonAsync(new ApiErrorResponse(
135138
options.Scope.Add("read:user");
136139
options.Scope.Add("user:email");
137140

141+
// Ensure any additional RequiredScopes from configuration are also
142+
// requested from GitHub. Without this, configuring a required scope
143+
// that isn't requested causes all logins to be rejected.
144+
foreach (var scope in gitHubOAuthSettings.RequiredScopes ?? Enumerable.Empty<string>())
145+
{
146+
if (!options.Scope.Contains(scope))
147+
options.Scope.Add(scope);
148+
}
149+
foreach (var scope in gitHubOAuthSettings.ExpectedScopes ?? Enumerable.Empty<string>())
150+
{
151+
if (!options.Scope.Contains(scope))
152+
options.Scope.Add(scope);
153+
}
154+
138155
options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
139156
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "login");
140157
options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
141158
options.ClaimActions.MapJsonKey("urn:github:name", "name");
142159
options.ClaimActions.MapJsonKey("urn:github:login", "login");
143160
options.ClaimActions.MapJsonKey("urn:github:avatar", "avatar_url");
144161

145-
// OAuthHandler fetches UserInformationEndpoint automatically
146-
// and applies ClaimActions — no custom OnCreatingTicket needed.
162+
// Validate OAuth scopes after token exchange.
163+
// GitHub returns the granted scopes in the token response body as "scope"
164+
// (comma-separated). Reject authentication if required scopes are missing.
165+
var scopeSettings = gitHubOAuthSettings;
166+
options.Events = new OAuthEvents
167+
{
168+
OnCreatingTicket = context =>
169+
{
170+
var scopeValidator = context.HttpContext.RequestServices
171+
.GetRequiredService<OAuthScopeValidator>();
172+
173+
// GitHub returns granted scopes in the token response "scope" field.
174+
// The X-OAuth-Scopes header appears on API responses, but the token
175+
// response body is the authoritative source at auth time.
176+
var grantedScopesRaw = context.TokenResponse?.Response?.RootElement
177+
.TryGetProperty("scope", out var scopeElement) == true
178+
? scopeElement.GetString()
179+
: null;
180+
181+
var validationResult = scopeValidator.Validate(
182+
grantedScopesRaw,
183+
scopeSettings.RequiredScopes,
184+
scopeSettings.ExpectedScopes);
185+
186+
if (!validationResult.IsValid)
187+
{
188+
var logger = context.HttpContext.RequestServices
189+
.GetRequiredService<ILoggerFactory>()
190+
.CreateLogger("Taskdeck.Api.OAuth.ScopeValidation");
191+
192+
logger.LogError(
193+
"GitHub OAuth authentication rejected: missing required scopes. " +
194+
"Missing: [{MissingScopes}]. User must re-authorize with required permissions.",
195+
string.Join(", ", validationResult.MissingRequiredScopes));
196+
197+
context.Fail(validationResult.ErrorMessage
198+
?? "GitHub OAuth scope validation failed");
199+
}
200+
201+
return Task.CompletedTask;
202+
}
203+
};
147204

148205
// Circuit breaker for the OAuth backchannel (token exchange + user info).
149206
if (circuitBreakerTracker is not null && circuitBreakerSettings is not null)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using Taskdeck.Application.Interfaces;
2+
using Taskdeck.Application.Services;
3+
4+
namespace Taskdeck.Api.Extensions;
5+
6+
/// <summary>
7+
/// Registers the minimal subset of Application services required by MCP
8+
/// resources and tools. Both MCP stdio and MCP HTTP standalone modes call
9+
/// this instead of the full <see cref="ApplicationServiceRegistration.AddApplicationServices"/>
10+
/// which includes web-only services (SignalR notifiers, workers, LLM providers, etc.).
11+
/// </summary>
12+
public static class McpApplicationServiceRegistration
13+
{
14+
/// <summary>
15+
/// Register the Application services that MCP resources and tools depend on.
16+
/// Deliberately skips web-only services (SignalR notifiers, workers,
17+
/// LLM providers, rate limiting, etc.) to keep the MCP host minimal.
18+
/// </summary>
19+
public static IServiceCollection AddMcpApplicationServices(this IServiceCollection services)
20+
{
21+
services.AddScoped<AuthorizationService>();
22+
services.AddScoped<IAuthorizationService>(
23+
sp => sp.GetRequiredService<AuthorizationService>());
24+
services.AddScoped<BoardService>(sp =>
25+
new BoardService(
26+
sp.GetRequiredService<IUnitOfWork>(),
27+
sp.GetRequiredService<IAuthorizationService>()));
28+
services.AddScoped<ColumnService>();
29+
services.AddScoped<CardService>();
30+
services.AddScoped<LabelService>();
31+
services.AddScoped<AutomationProposalService>();
32+
services.AddScoped<IAutomationProposalService>(
33+
sp => sp.GetRequiredService<AutomationProposalService>());
34+
services.AddScoped<CaptureService>();
35+
services.AddScoped<ICaptureService>(
36+
sp => sp.GetRequiredService<CaptureService>());
37+
services.AddScoped<NotificationService>();
38+
services.AddScoped<INotificationService>(
39+
sp => sp.GetRequiredService<NotificationService>());
40+
41+
return services;
42+
}
43+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using ModelContextProtocol.AspNetCore;
2+
using ModelContextProtocol.Server;
3+
using Taskdeck.Api.Mcp;
4+
5+
namespace Taskdeck.Api.Extensions;
6+
7+
/// <summary>
8+
/// Registers the shared set of MCP resources and tools used by all three
9+
/// hosting modes (co-hosted web, standalone HTTP, and stdio).
10+
/// </summary>
11+
public static class McpResourcesAndToolsRegistration
12+
{
13+
/// <summary>
14+
/// Add MCP resources (Board, Capture, Proposal) and tools (Read, Write, Proposal)
15+
/// to the given MCP server builder.
16+
/// </summary>
17+
public static IMcpServerBuilder AddMcpResourcesAndTools(this IMcpServerBuilder builder)
18+
{
19+
return builder
20+
.WithResources<BoardResources>()
21+
.WithResources<CaptureResources>()
22+
.WithResources<ProposalResources>()
23+
.WithTools<ReadTools>()
24+
.WithTools<WriteTools>()
25+
.WithTools<ProposalTools>();
26+
}
27+
}

backend/src/Taskdeck.Api/Extensions/OptionsValidationRegistration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public static IServiceCollection AddOptionsValidation(
4040
// ── Settings from WorkerRegistration ────────────────────────────────
4141

4242
services.RegisterValidatedOptions<WorkerSettings>(configuration, "Workers");
43+
services.RegisterValidatedOptions<AuditRetentionSettings>(configuration, "AuditRetention");
4344

4445
// ── Settings from CorsRegistration (Cache is used in infrastructure) ─
4546

backend/src/Taskdeck.Api/Extensions/WorkerRegistration.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,14 @@ public static IServiceCollection AddTaskdeckWorkers(
3939
};
4040
});
4141

42+
var auditRetentionSettings = configuration.GetSection("AuditRetention").Get<AuditRetentionSettings>() ?? new AuditRetentionSettings();
43+
services.AddSingleton(auditRetentionSettings);
44+
4245
services.AddSingleton<WorkerHeartbeatRegistry>();
4346
services.AddHostedService<LlmQueueToProposalWorker>();
4447
services.AddHostedService<ProposalHousekeepingWorker>();
4548
services.AddHostedService<OutboundWebhookDeliveryWorker>();
49+
services.AddHostedService<AuditRetentionWorker>();
4650

4751
return services;
4852
}

backend/src/Taskdeck.Api/Program.cs

Lines changed: 7 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -69,26 +69,8 @@
6969
// Infrastructure (DbContext, Repositories, UoW)
7070
mcpHttpBuilder.Services.AddInfrastructure(mcpHttpBuilder.Configuration);
7171

72-
// Register Application services needed by MCP resources and tools.
73-
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.AuthorizationService>();
74-
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.IAuthorizationService>(
75-
sp => sp.GetRequiredService<Taskdeck.Application.Services.AuthorizationService>());
76-
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.BoardService>(sp =>
77-
new Taskdeck.Application.Services.BoardService(
78-
sp.GetRequiredService<IUnitOfWork>(),
79-
sp.GetRequiredService<Taskdeck.Application.Services.IAuthorizationService>()));
80-
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.ColumnService>();
81-
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.CardService>();
82-
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.LabelService>();
83-
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.AutomationProposalService>();
84-
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.IAutomationProposalService>(
85-
sp => sp.GetRequiredService<Taskdeck.Application.Services.AutomationProposalService>());
86-
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.CaptureService>();
87-
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.ICaptureService>(
88-
sp => sp.GetRequiredService<Taskdeck.Application.Services.CaptureService>());
89-
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.NotificationService>();
90-
mcpHttpBuilder.Services.AddScoped<Taskdeck.Application.Services.INotificationService>(
91-
sp => sp.GetRequiredService<Taskdeck.Application.Services.NotificationService>());
72+
// Application services needed by MCP resources and tools (shared with stdio mode).
73+
mcpHttpBuilder.Services.AddMcpApplicationServices();
9274

9375
// HTTP identity: maps API key to user via HttpUserContextProvider.
9476
mcpHttpBuilder.Services.AddHttpContextAccessor();
@@ -119,12 +101,7 @@
119101
// MCP server: HTTP transport + all resources and tools.
120102
mcpHttpBuilder.Services.AddMcpServer()
121103
.WithHttpTransport()
122-
.WithResources<BoardResources>()
123-
.WithResources<CaptureResources>()
124-
.WithResources<ProposalResources>()
125-
.WithTools<ReadTools>()
126-
.WithTools<WriteTools>()
127-
.WithTools<ProposalTools>();
104+
.AddMcpResourcesAndTools();
128105

129106
var mcpHttpApp = mcpHttpBuilder.Build();
130107

@@ -189,28 +166,8 @@
189166
// Infrastructure (DbContext, Repositories, UoW)
190167
services.AddInfrastructure(ctx.Configuration);
191168

192-
// Register Application services needed by MCP resources and tools.
193-
// We deliberately skip web-only services (SignalR notifiers, workers,
194-
// LLM providers, rate limiting, etc.) to keep the MCP host minimal.
195-
services.AddScoped<Taskdeck.Application.Services.AuthorizationService>();
196-
services.AddScoped<Taskdeck.Application.Services.IAuthorizationService>(
197-
sp => sp.GetRequiredService<Taskdeck.Application.Services.AuthorizationService>());
198-
services.AddScoped<Taskdeck.Application.Services.BoardService>(sp =>
199-
new Taskdeck.Application.Services.BoardService(
200-
sp.GetRequiredService<IUnitOfWork>(),
201-
sp.GetRequiredService<Taskdeck.Application.Services.IAuthorizationService>()));
202-
services.AddScoped<Taskdeck.Application.Services.ColumnService>();
203-
services.AddScoped<Taskdeck.Application.Services.CardService>();
204-
services.AddScoped<Taskdeck.Application.Services.LabelService>();
205-
services.AddScoped<Taskdeck.Application.Services.AutomationProposalService>();
206-
services.AddScoped<Taskdeck.Application.Services.IAutomationProposalService>(
207-
sp => sp.GetRequiredService<Taskdeck.Application.Services.AutomationProposalService>());
208-
services.AddScoped<Taskdeck.Application.Services.CaptureService>();
209-
services.AddScoped<Taskdeck.Application.Services.ICaptureService>(
210-
sp => sp.GetRequiredService<Taskdeck.Application.Services.CaptureService>());
211-
services.AddScoped<Taskdeck.Application.Services.NotificationService>();
212-
services.AddScoped<Taskdeck.Application.Services.INotificationService>(
213-
sp => sp.GetRequiredService<Taskdeck.Application.Services.NotificationService>());
169+
// Application services needed by MCP resources and tools (shared with HTTP mode).
170+
services.AddMcpApplicationServices();
214171

215172
// Stdio identity: maps the OS process owner to the local default user.
216173
services.AddScoped<IUserContextProvider, StdioUserContextProvider>();
@@ -221,12 +178,7 @@
221178
// MCP server: stdio transport + all resources and tools.
222179
services.AddMcpServer()
223180
.WithStdioServerTransport()
224-
.WithResources<BoardResources>()
225-
.WithResources<CaptureResources>()
226-
.WithResources<ProposalResources>()
227-
.WithTools<ReadTools>()
228-
.WithTools<WriteTools>()
229-
.WithTools<ProposalTools>();
181+
.AddMcpResourcesAndTools();
230182
})
231183
.Build();
232184

@@ -357,12 +309,7 @@
357309
builder.Services.AddMcpTelemetry();
358310
builder.Services.AddMcpServer()
359311
.WithHttpTransport()
360-
.WithResources<BoardResources>()
361-
.WithResources<CaptureResources>()
362-
.WithResources<ProposalResources>()
363-
.WithTools<ReadTools>()
364-
.WithTools<WriteTools>()
365-
.WithTools<ProposalTools>();
312+
.AddMcpResourcesAndTools();
366313

367314
// Add JWT Authentication (with optional GitHub OAuth and OIDC providers, circuit-breaker-protected backchannel)
368315
// CircuitBreakerStateTracker is already registered as a singleton by AddLlmProviders above.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
using Taskdeck.Application.Interfaces;
2+
using Taskdeck.Application.Services;
3+
using Taskdeck.Api.Telemetry;
4+
5+
namespace Taskdeck.Api.Workers;
6+
7+
/// <summary>
8+
/// Background worker that periodically deletes audit log entries
9+
/// older than the configured retention period.
10+
/// </summary>
11+
public class AuditRetentionWorker : BackgroundService
12+
{
13+
private readonly IServiceScopeFactory _scopeFactory;
14+
private readonly AuditRetentionSettings _settings;
15+
private readonly WorkerHeartbeatRegistry _workerHeartbeatRegistry;
16+
private readonly ILogger<AuditRetentionWorker> _logger;
17+
18+
public AuditRetentionWorker(
19+
IServiceScopeFactory scopeFactory,
20+
AuditRetentionSettings settings,
21+
WorkerHeartbeatRegistry workerHeartbeatRegistry,
22+
ILogger<AuditRetentionWorker> logger)
23+
{
24+
_scopeFactory = scopeFactory;
25+
_settings = settings;
26+
_workerHeartbeatRegistry = workerHeartbeatRegistry;
27+
_logger = logger;
28+
}
29+
30+
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
31+
{
32+
_logger.LogInformation(
33+
"AuditRetentionWorker starting (retention={RetentionDays}d, batch={BatchSize}, interval={IntervalHours}h)",
34+
_settings.MaxRetentionDays,
35+
_settings.CleanupBatchSize,
36+
_settings.CleanupIntervalHours);
37+
38+
while (!stoppingToken.IsCancellationRequested)
39+
{
40+
_workerHeartbeatRegistry.ReportHeartbeat(nameof(AuditRetentionWorker));
41+
42+
try
43+
{
44+
await CleanupOldEntriesAsync(stoppingToken);
45+
}
46+
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
47+
{
48+
break;
49+
}
50+
catch (Exception ex)
51+
{
52+
_logger.LogError(
53+
"Error in AuditRetentionWorker iteration. {ExceptionSummary}",
54+
SensitiveDataRedactor.SummarizeException(ex));
55+
}
56+
57+
await Task.Delay(TimeSpan.FromHours(_settings.CleanupIntervalHours), stoppingToken);
58+
}
59+
60+
_logger.LogInformation("AuditRetentionWorker stopped");
61+
}
62+
63+
internal async Task CleanupOldEntriesAsync(CancellationToken ct)
64+
{
65+
using var activity = TaskdeckTelemetry.ActivitySource.StartActivity(
66+
"taskdeck.worker.audit_retention_cleanup",
67+
System.Diagnostics.ActivityKind.Internal);
68+
activity?.SetTag(TaskdeckTelemetryTags.WorkerName, nameof(AuditRetentionWorker));
69+
70+
var cutoff = DateTimeOffset.UtcNow.AddDays(-_settings.MaxRetentionDays);
71+
72+
using var scope = _scopeFactory.CreateScope();
73+
var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
74+
75+
var totalDeleted = await auditRepo.DeleteOldEntriesAsync(
76+
cutoff,
77+
_settings.CleanupBatchSize,
78+
ct);
79+
80+
if (totalDeleted > 0)
81+
{
82+
_logger.LogInformation(
83+
"AuditRetentionWorker deleted {Count} entries older than {CutoffDate:u}",
84+
totalDeleted,
85+
cutoff);
86+
}
87+
else
88+
{
89+
_logger.LogDebug(
90+
"AuditRetentionWorker found no entries older than {CutoffDate:u}",
91+
cutoff);
92+
}
93+
94+
activity?.SetTag("taskdeck.audit.deleted_count", totalDeleted);
95+
}
96+
}

backend/src/Taskdeck.Api/appsettings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
"Microsoft.AspNetCore": "Warning"
66
}
77
},
8+
"AuditRetention": {
9+
"MaxRetentionDays": 90,
10+
"CleanupBatchSize": 1000,
11+
"CleanupIntervalHours": 24
12+
},
813
"Workers": {
914
"QueuePollIntervalSeconds": 5,
1015
"MaxBatchSize": 5,

0 commit comments

Comments
 (0)