c0bf69ad56
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>
107 lines
4.4 KiB
C#
107 lines
4.4 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/rules")]
|
|
[Authorize(AuthenticationSchemes = "local")]
|
|
public class RulesController : ControllerBase
|
|
{
|
|
private readonly JobTrackerContext _db;
|
|
|
|
public RulesController(JobTrackerContext db)
|
|
{
|
|
_db = db;
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<ActionResult<RuleSettings>> Get(CancellationToken cancellationToken)
|
|
{
|
|
// Per-user rule settings when authenticated.
|
|
if (!string.IsNullOrWhiteSpace(_db.CurrentUserId))
|
|
{
|
|
var u = await _db.UserRuleSettings.FirstOrDefaultAsync(x => x.OwnerUserId == _db.CurrentUserId, cancellationToken);
|
|
if (u is null)
|
|
{
|
|
u = new UserRuleSettings { OwnerUserId = _db.CurrentUserId };
|
|
_db.UserRuleSettings.Add(u);
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
return Ok(new RuleSettings
|
|
{
|
|
Id = 1,
|
|
AppliedFollowUpDays = u.AppliedFollowUpDays,
|
|
AppliedGhostDays = u.AppliedGhostDays,
|
|
OfferFollowUpDays = u.OfferFollowUpDays,
|
|
OfferGhostDays = u.OfferGhostDays,
|
|
FeedbackFollowUpDays = u.FeedbackFollowUpDays,
|
|
FeedbackGhostDays = u.FeedbackGhostDays,
|
|
});
|
|
}
|
|
|
|
// Fallback global settings.
|
|
var s = await _db.RuleSettings.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
|
|
if (s is null)
|
|
{
|
|
s = new RuleSettings { Id = 1 };
|
|
_db.RuleSettings.Add(s);
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
}
|
|
return Ok(s);
|
|
}
|
|
|
|
[HttpPut]
|
|
public async Task<IActionResult> Update([FromBody] RuleSettings incoming, CancellationToken cancellationToken)
|
|
{
|
|
// Per-user rule settings when authenticated.
|
|
if (!string.IsNullOrWhiteSpace(_db.CurrentUserId))
|
|
{
|
|
var s = await _db.UserRuleSettings.FirstOrDefaultAsync(x => x.OwnerUserId == _db.CurrentUserId, cancellationToken);
|
|
if (s is null)
|
|
{
|
|
s = new UserRuleSettings { OwnerUserId = _db.CurrentUserId };
|
|
_db.UserRuleSettings.Add(s);
|
|
}
|
|
|
|
s.AppliedFollowUpDays = Clamp(incoming.AppliedFollowUpDays, 1, 365);
|
|
s.AppliedGhostDays = Clamp(incoming.AppliedGhostDays, 1, 365);
|
|
s.OfferFollowUpDays = Clamp(incoming.OfferFollowUpDays, 1, 365);
|
|
s.OfferGhostDays = Clamp(incoming.OfferGhostDays, 1, 365);
|
|
s.FeedbackFollowUpDays = Clamp(incoming.FeedbackFollowUpDays, 1, 365);
|
|
s.FeedbackGhostDays = Clamp(incoming.FeedbackGhostDays, 1, 365);
|
|
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
// Fallback global settings.
|
|
var g = await _db.RuleSettings.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
|
|
if (g is null)
|
|
{
|
|
g = new RuleSettings { Id = 1 };
|
|
_db.RuleSettings.Add(g);
|
|
}
|
|
|
|
g.AppliedFollowUpDays = Clamp(incoming.AppliedFollowUpDays, 1, 365);
|
|
g.AppliedGhostDays = Clamp(incoming.AppliedGhostDays, 1, 365);
|
|
g.OfferFollowUpDays = Clamp(incoming.OfferFollowUpDays, 1, 365);
|
|
g.OfferGhostDays = Clamp(incoming.OfferGhostDays, 1, 365);
|
|
g.FeedbackFollowUpDays = Clamp(incoming.FeedbackFollowUpDays, 1, 365);
|
|
g.FeedbackGhostDays = Clamp(incoming.FeedbackGhostDays, 1, 365);
|
|
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
private static int Clamp(int v, int min, int max) => v < min ? min : v > max ? max : v;
|
|
}
|
|
}
|