Files
jobtrackingapp/JobTrackerApi/Controllers/CorrespondenceController.cs
T
cesnimda c0bf69ad56
CI and Deploy / test (push) Failing after 1m10s
CI and Deploy / deploy (push) Has been skipped
fix(security): enforce explicit api authorization
Authentication relied on a fallback policy gated on Auth:Require, which defaults
to false. Five user-owned controllers carried no [Authorize] of their own, so a
deployment that lost that flag would have served tenant data anonymously:
JobApplications, Companies, Correspondence, Rules and JobImport. All five now
declare [Authorize(AuthenticationSchemes = "local")] explicitly.

This does not affect local development, which already sets Auth:Require=true in
appsettings.Development.json — the gap was only ever in a production
configuration that omitted the flag.

Added a reflection test over every controller in the assembly so a new one
cannot ship unprotected by accident. A controller passes if the class requires
authorization, or if every action declares its own [Authorize] or
[AllowAnonymous] — the shape AuthController and TwoFactorController need, since
login and register must stay anonymous while the rest must not. Public endpoints
are an explicit allow-list, so making something anonymous is now a deliberate
edit rather than an omission.

That test found one real gap: AuthController.Logout declared neither attribute.
It is now explicitly [AllowAnonymous] — it only clears the caller's own session
cookies and leaks nothing, and requiring authentication would leave a user whose
token had already expired unable to sign out.

Also pinned: admin controllers require the Admin role rather than merely a
signed-in user, and PublicCvController stays anonymous so shared CV links keep
working.

384 backend tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:27:27 +02:00

191 lines
8.2 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
namespace JobTrackerApi.Controllers
{
[ApiController]
// Explicitly authorized. These endpoints are all tenant-scoped user data, so they must not
// depend on the Auth:Require fallback policy being switched on: a deployment that lost that flag
// would otherwise serve them anonymously. docs/production-readiness-review.md.
[Route("api/correspondence")]
[Authorize(AuthenticationSchemes = "local")]
public class CorrespondenceController : ControllerBase
{
private readonly JobTrackerContext _db;
public CorrespondenceController(JobTrackerContext db)
{
_db = db;
}
// Resolve correspondence through its parent job so the DbContext's user-scoped
// job filter still protects raw id endpoints in multi-user deployments.
private Task<Correspondence?> FindOwnedMessageAsync(int correspondenceId, CancellationToken cancellationToken)
{
return _db.Correspondences
.Include(c => c.JobApplication)
.FirstOrDefaultAsync(c => c.Id == correspondenceId, cancellationToken);
}
public sealed record CorrespondenceInboxItemDto(
int Id,
int JobApplicationId,
string? CompanyName,
string? JobTitle,
string From,
string? Direction,
string? Subject,
string? Channel,
DateTime Date,
string ContentPreview,
string? ExternalThreadId,
string? ExternalFrom,
string? ExternalTo,
int LabelCount,
int AttachmentCount);
[HttpGet]
public async Task<ActionResult<List<CorrespondenceInboxItemDto>>> GetInbox(
[FromQuery] string? q,
[FromQuery] string? direction,
[FromQuery] string? linkState,
CancellationToken cancellationToken)
{
var query = _db.Correspondences
.Include(c => c.JobApplication)
.ThenInclude(j => j.Company)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(q))
{
var needle = q.Trim();
query = query.Where(c =>
(c.Subject != null && EF.Functions.Like(c.Subject, $"%{needle}%")) ||
EF.Functions.Like(c.Content, $"%{needle}%") ||
(c.ExternalFrom != null && EF.Functions.Like(c.ExternalFrom, $"%{needle}%")) ||
(c.JobApplication.JobTitle != null && EF.Functions.Like(c.JobApplication.JobTitle, $"%{needle}%")) ||
(c.JobApplication.Company.Name != null && EF.Functions.Like(c.JobApplication.Company.Name, $"%{needle}%")));
}
if (!string.IsNullOrWhiteSpace(direction) && !string.Equals(direction, "all", StringComparison.OrdinalIgnoreCase))
{
query = query.Where(c => c.Direction == direction);
}
if (string.Equals(linkState, "linked", StringComparison.OrdinalIgnoreCase))
{
query = query.Where(c => c.ExternalThreadId != null);
}
else if (string.Equals(linkState, "manual", StringComparison.OrdinalIgnoreCase))
{
query = query.Where(c => c.ExternalThreadId == null);
}
var items = await query
.OrderByDescending(c => c.Date)
.Take(200)
.Select(c => new CorrespondenceInboxItemDto(
c.Id,
c.JobApplicationId,
c.JobApplication.Company != null ? c.JobApplication.Company.Name : null,
c.JobApplication.JobTitle,
c.From,
c.Direction,
c.Subject,
c.Channel,
c.Date,
c.Content.Length <= 220 ? c.Content : c.Content.Substring(0, 220),
c.ExternalThreadId,
c.ExternalFrom,
c.ExternalTo,
c.ExternalLabelsJson != null ? 1 : 0,
c.AttachmentMetadataJson != null ? 1 : 0))
.ToListAsync(cancellationToken);
return Ok(items);
}
// GET all messages for a job
[HttpGet("{jobId:int}")]
public async Task<ActionResult<List<Correspondence>>> GetForJob([FromRoute] int jobId, CancellationToken cancellationToken)
{
var jobOk = await _db.JobApplications.AnyAsync(j => j.Id == jobId, cancellationToken);
if (!jobOk) return NotFound();
var messages = await _db.Correspondences
.Where(c => c.JobApplicationId == jobId)
.OrderBy(c => c.Date)
.ToListAsync(cancellationToken);
return Ok(messages);
}
public sealed record CreateCorrespondenceRequest(int JobApplicationId, string From, string Content);
public sealed record CreateCorrespondenceRequestV2(
int JobApplicationId,
string From,
string Content,
string? Subject,
string? Channel,
DateTime? Date,
string? Direction,
string? ExternalMessageId,
string? ExternalThreadId,
string? ExternalFrom,
string? ExternalTo,
string? ExternalLabelsJson,
string? AttachmentMetadataJson
);
// POST new message
[HttpPost]
public async Task<ActionResult<Correspondence>> Create([FromBody] CreateCorrespondenceRequestV2 request, CancellationToken cancellationToken)
{
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
if (string.IsNullOrWhiteSpace(request.From)) return BadRequest("From is required.");
if (string.IsNullOrWhiteSpace(request.Content)) return BadRequest("Content is required.");
var exists = await _db.JobApplications.AnyAsync(j => j.Id == request.JobApplicationId, cancellationToken);
if (!exists) return BadRequest("jobApplicationId does not exist.");
var message = new Correspondence
{
JobApplicationId = request.JobApplicationId,
From = request.From.Trim(),
Subject = string.IsNullOrWhiteSpace(request.Subject) ? null : request.Subject.Trim(),
Channel = string.IsNullOrWhiteSpace(request.Channel) ? null : request.Channel.Trim(),
Direction = string.IsNullOrWhiteSpace(request.Direction) ? null : request.Direction.Trim(),
ExternalMessageId = string.IsNullOrWhiteSpace(request.ExternalMessageId) ? null : request.ExternalMessageId.Trim(),
ExternalThreadId = string.IsNullOrWhiteSpace(request.ExternalThreadId) ? null : request.ExternalThreadId.Trim(),
ExternalFrom = string.IsNullOrWhiteSpace(request.ExternalFrom) ? null : request.ExternalFrom.Trim(),
ExternalTo = string.IsNullOrWhiteSpace(request.ExternalTo) ? null : request.ExternalTo.Trim(),
ExternalLabelsJson = string.IsNullOrWhiteSpace(request.ExternalLabelsJson) ? null : request.ExternalLabelsJson.Trim(),
AttachmentMetadataJson = string.IsNullOrWhiteSpace(request.AttachmentMetadataJson) ? null : request.AttachmentMetadataJson.Trim(),
Provider = "manual",
Content = request.Content,
Date = request.Date ?? DateTime.Now,
};
_db.Correspondences.Add(message);
await _db.SaveChangesAsync(cancellationToken);
return CreatedAtAction(nameof(GetForJob), new { jobId = message.JobApplicationId }, message);
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete([FromRoute] int id, CancellationToken cancellationToken)
{
var message = await FindOwnedMessageAsync(id, cancellationToken);
if (message is null) return NotFound();
_db.Correspondences.Remove(message);
await _db.SaveChangesAsync(cancellationToken);
return NoContent();
}
}
}