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>
156 lines
6.3 KiB
C#
156 lines
6.3 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using System.Security.Claims;
|
|
|
|
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/companies")]
|
|
[Authorize(AuthenticationSchemes = "local")]
|
|
public class CompaniesController : ControllerBase
|
|
{
|
|
private readonly JobTrackerContext _db;
|
|
|
|
public CompaniesController(JobTrackerContext db)
|
|
{
|
|
_db = db;
|
|
}
|
|
|
|
private string? CurrentUserId =>
|
|
User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? User?.FindFirstValue("sub");
|
|
|
|
private static string? NormalizeSource(string? source)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(source)) return null;
|
|
var value = source.Trim();
|
|
|
|
if (Uri.TryCreate(value, UriKind.Absolute, out var uri) && !string.IsNullOrWhiteSpace(uri.Host))
|
|
{
|
|
value = uri.Host;
|
|
}
|
|
|
|
value = value.Replace("www.", "", StringComparison.OrdinalIgnoreCase).Trim().Trim('/');
|
|
var lower = value.ToLowerInvariant();
|
|
|
|
return lower switch
|
|
{
|
|
"linkedin" or "linkedin.com" => "LinkedIn",
|
|
"finn" or "finn.no" => "Finn",
|
|
"nav" or "nav.no" => "NAV",
|
|
"jobbnorge" or "jobbnorge.no" => "Jobbnorge",
|
|
_ => string.Join(" ", value.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries).Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant()))
|
|
};
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<ActionResult<List<Company>>> GetAll(CancellationToken cancellationToken)
|
|
{
|
|
var userId = CurrentUserId;
|
|
var q = _db.Companies.AsQueryable();
|
|
if (!string.IsNullOrWhiteSpace(userId))
|
|
q = q.Where(c => c.OwnerUserId == userId);
|
|
|
|
var companies = await q
|
|
.OrderBy(c => c.Name)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return Ok(companies);
|
|
}
|
|
|
|
[HttpGet("{id:int}")]
|
|
public async Task<ActionResult<Company>> GetById([FromRoute] int id, CancellationToken cancellationToken)
|
|
{
|
|
var userId = CurrentUserId;
|
|
var q = _db.Companies.AsQueryable();
|
|
if (!string.IsNullOrWhiteSpace(userId))
|
|
q = q.Where(c => c.OwnerUserId == userId);
|
|
|
|
var company = await q.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
|
|
if (company is null) return NotFound();
|
|
return Ok(company);
|
|
}
|
|
|
|
public sealed record CreateCompanyRequest(string Name, string? Location, string? Source);
|
|
public sealed record UpdateCompanyRequest(
|
|
string Name,
|
|
string? Location,
|
|
string? Source,
|
|
string? RecruiterName,
|
|
string? RecruiterEmail,
|
|
string? RecruiterLinkedIn,
|
|
DateTime? LastContactedAt,
|
|
DateTime? NextContactAt,
|
|
string? PipelineStage
|
|
);
|
|
|
|
[HttpPost]
|
|
public async Task<ActionResult<Company>> Create([FromBody] CreateCompanyRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var userId = CurrentUserId;
|
|
var name = (request.Name ?? "").Trim();
|
|
if (name.Length == 0) return BadRequest("Company name is required.");
|
|
|
|
var existingQuery = _db.Companies.AsQueryable();
|
|
if (!string.IsNullOrWhiteSpace(userId))
|
|
existingQuery = existingQuery.Where(c => c.OwnerUserId == userId);
|
|
|
|
var existing = await existingQuery
|
|
.FirstOrDefaultAsync(c => c.Name.ToLower() == name.ToLower(), cancellationToken);
|
|
|
|
if (existing is not null)
|
|
{
|
|
// Idempotent create: return existing instead of failing.
|
|
return Ok(existing);
|
|
}
|
|
|
|
var company = new Company
|
|
{
|
|
OwnerUserId = string.IsNullOrWhiteSpace(userId) ? null : userId,
|
|
Name = name,
|
|
Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(),
|
|
Source = NormalizeSource(request.Source),
|
|
};
|
|
|
|
_db.Companies.Add(company);
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
|
|
return CreatedAtAction(nameof(GetById), new { id = company.Id }, company);
|
|
}
|
|
|
|
[HttpPut("{id:int}")]
|
|
public async Task<ActionResult<Company>> Update([FromRoute] int id, [FromBody] UpdateCompanyRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var userId = CurrentUserId;
|
|
var q = _db.Companies.AsQueryable();
|
|
if (!string.IsNullOrWhiteSpace(userId))
|
|
q = q.Where(c => c.OwnerUserId == userId);
|
|
|
|
var company = await q.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
|
|
if (company is null) return NotFound();
|
|
|
|
var name = (request.Name ?? "").Trim();
|
|
if (name.Length == 0) return BadRequest("Company name is required.");
|
|
|
|
company.Name = name;
|
|
company.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim();
|
|
company.Source = NormalizeSource(request.Source);
|
|
|
|
company.RecruiterName = string.IsNullOrWhiteSpace(request.RecruiterName) ? null : request.RecruiterName.Trim();
|
|
company.RecruiterEmail = string.IsNullOrWhiteSpace(request.RecruiterEmail) ? null : request.RecruiterEmail.Trim();
|
|
company.RecruiterLinkedIn = string.IsNullOrWhiteSpace(request.RecruiterLinkedIn) ? null : request.RecruiterLinkedIn.Trim();
|
|
company.PipelineStage = string.IsNullOrWhiteSpace(request.PipelineStage) ? null : request.PipelineStage.Trim();
|
|
company.LastContactedAt = request.LastContactedAt;
|
|
company.NextContactAt = request.NextContactAt;
|
|
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
return Ok(company);
|
|
}
|
|
}
|
|
}
|