Files
jobtrackingapp/JobTrackerApi/Controllers/RulesController.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

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;
}
}