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

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