Security hardening

- Remove OwnerUserId from BudgetDto: OIDC sub of the budget owner was
  being returned to all collaborators (including View-only users)
- Remove SharedWithUserId from ShareDto: other users' internal OIDC subs
  were visible to anyone with read access to a budget
- Delete MeController: scaffolding endpoint that returned sub to the
  browser; no legitimate frontend use case
- Restrict /healthz to require authorization: prevents unauthenticated
  probing of database connectivity
- Add input validation annotations to all request DTOs: [Required],
  [MaxLength], [Range(0,0.9999)] on EffectiveTaxRate, [EmailAddress] on
  share email — [ApiController] now returns 400 instead of 500 for
  invalid input hitting DB constraints
- Replace User.FindFirst("sub")!.Value with GetUserId() extension across
  all controllers: returns 401 instead of NullReferenceException (500)
  if a token lacks a sub claim

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Spencer Twaddle
2026-04-25 09:00:33 -05:00
parent a8cf6957b5
commit 6d1bc2ce2c
14 changed files with 131 additions and 77 deletions
+20 -11
View File
@@ -13,63 +13,72 @@ namespace Budget.Api.Controllers;
[Authorize] [Authorize]
public class BudgetsController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase public class BudgetsController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
{ {
private string UserId => User.FindFirst("sub")!.Value; private IActionResult? TryGetUserId(out string userId)
{
var id = User.GetUserId();
if (id is null) { userId = string.Empty; return Unauthorized(); }
userId = id;
return null;
}
[HttpGet] [HttpGet]
public async Task<IActionResult> List() public async Task<IActionResult> List()
{ {
var userId = UserId; if (TryGetUserId(out var userId) is { } err) return err;
var budgets = await db.Budgets var budgets = await db.Budgets
.Where(b => b.OwnerUserId == userId || .Where(b => b.OwnerUserId == userId ||
b.Shares.Any(s => s.SharedWithUserId == userId && !s.IsPending)) b.Shares.Any(s => s.SharedWithUserId == userId && !s.IsPending))
.Select(b => new BudgetDto(b.Id, b.Name, b.OwnerUserId, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt)) .Select(b => new BudgetDto(b.Id, b.Name, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt))
.ToListAsync(); .ToListAsync();
return Ok(budgets); return Ok(budgets);
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> Create([FromBody] CreateBudgetRequest req) public async Task<IActionResult> Create([FromBody] CreateBudgetRequest req)
{ {
if (TryGetUserId(out var userId) is { } err) return err;
var budget = new Models.Budget var budget = new Models.Budget
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Name = req.Name, Name = req.Name,
OwnerUserId = UserId, OwnerUserId = userId,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
}; };
db.Budgets.Add(budget); db.Budgets.Add(budget);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return CreatedAtAction(nameof(Get), new { id = budget.Id }, return CreatedAtAction(nameof(Get), new { id = budget.Id },
new BudgetDto(budget.Id, budget.Name, budget.OwnerUserId, budget.EffectiveTaxRate, budget.CreatedAt, budget.UpdatedAt)); new BudgetDto(budget.Id, budget.Name, budget.EffectiveTaxRate, budget.CreatedAt, budget.UpdatedAt));
} }
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id) public async Task<IActionResult> Get(Guid id)
{ {
if (!await authz.CanReadAsync(id, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.CanReadAsync(id, userId)) return Forbid();
var b = await db.Budgets.FindAsync(id); var b = await db.Budgets.FindAsync(id);
if (b is null) return NotFound(); if (b is null) return NotFound();
return Ok(new BudgetDto(b.Id, b.Name, b.OwnerUserId, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt)); return Ok(new BudgetDto(b.Id, b.Name, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt));
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateBudgetRequest req) public async Task<IActionResult> Update(Guid id, [FromBody] UpdateBudgetRequest req)
{ {
if (!await authz.CanWriteAsync(id, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.CanWriteAsync(id, userId)) return Forbid();
var b = await db.Budgets.FindAsync(id); var b = await db.Budgets.FindAsync(id);
if (b is null) return NotFound(); if (b is null) return NotFound();
b.Name = req.Name; b.Name = req.Name;
b.UpdatedAt = DateTimeOffset.UtcNow; b.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new BudgetDto(b.Id, b.Name, b.OwnerUserId, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt)); return Ok(new BudgetDto(b.Id, b.Name, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt));
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
if (!await authz.IsOwnerAsync(id, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.IsOwnerAsync(id, userId)) return Forbid();
var b = await db.Budgets.FindAsync(id); var b = await db.Budgets.FindAsync(id);
if (b is null) return NotFound(); if (b is null) return NotFound();
db.Budgets.Remove(b); db.Budgets.Remove(b);
@@ -13,7 +13,13 @@ namespace Budget.Api.Controllers;
[Authorize] [Authorize]
public class IncomesController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase public class IncomesController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
{ {
private string UserId => User.FindFirst("sub")!.Value; private IActionResult? TryGetUserId(out string userId)
{
var id = User.GetUserId();
if (id is null) { userId = string.Empty; return Unauthorized(); }
userId = id;
return null;
}
private static IncomeDto ToDto(Income i) => new( private static IncomeDto ToDto(Income i) => new(
i.Id, i.BudgetId, i.Name, i.Frequency, i.Amount, i.Id, i.BudgetId, i.Name, i.Frequency, i.Amount,
@@ -24,7 +30,8 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
[HttpGet] [HttpGet]
public async Task<IActionResult> List(Guid budgetId) public async Task<IActionResult> List(Guid budgetId)
{ {
if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
var incomes = await db.Incomes var incomes = await db.Incomes
.Where(i => i.BudgetId == budgetId) .Where(i => i.BudgetId == budgetId)
.OrderBy(i => i.SortOrder) .OrderBy(i => i.SortOrder)
@@ -35,7 +42,8 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
[HttpPost] [HttpPost]
public async Task<IActionResult> Create(Guid budgetId, [FromBody] CreateIncomeRequest req) public async Task<IActionResult> Create(Guid budgetId, [FromBody] CreateIncomeRequest req)
{ {
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
var maxOrder = await db.Incomes.Where(i => i.BudgetId == budgetId).MaxAsync(i => (int?)i.SortOrder) ?? -1; var maxOrder = await db.Incomes.Where(i => i.BudgetId == budgetId).MaxAsync(i => (int?)i.SortOrder) ?? -1;
var income = new Income var income = new Income
{ {
@@ -54,7 +62,8 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
[HttpPut("{incomeId:guid}")] [HttpPut("{incomeId:guid}")]
public async Task<IActionResult> Update(Guid budgetId, Guid incomeId, [FromBody] UpdateIncomeRequest req) public async Task<IActionResult> Update(Guid budgetId, Guid incomeId, [FromBody] UpdateIncomeRequest req)
{ {
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
var income = await db.Incomes.FirstOrDefaultAsync(i => i.Id == incomeId && i.BudgetId == budgetId); var income = await db.Incomes.FirstOrDefaultAsync(i => i.Id == incomeId && i.BudgetId == budgetId);
if (income is null) return NotFound(); if (income is null) return NotFound();
income.Name = req.Name; income.Name = req.Name;
@@ -67,7 +76,8 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
[HttpDelete("{incomeId:guid}")] [HttpDelete("{incomeId:guid}")]
public async Task<IActionResult> Delete(Guid budgetId, Guid incomeId) public async Task<IActionResult> Delete(Guid budgetId, Guid incomeId)
{ {
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
var income = await db.Incomes.FirstOrDefaultAsync(i => i.Id == incomeId && i.BudgetId == budgetId); var income = await db.Incomes.FirstOrDefaultAsync(i => i.Id == incomeId && i.BudgetId == budgetId);
if (income is null) return NotFound(); if (income is null) return NotFound();
db.Incomes.Remove(income); db.Incomes.Remove(income);
@@ -78,7 +88,8 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
[HttpPut("order")] [HttpPut("order")]
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] ReorderIncomesRequest req) public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] ReorderIncomesRequest req)
{ {
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync(); var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync();
var lookup = incomes.ToDictionary(i => i.Id); var lookup = incomes.ToDictionary(i => i.Id);
for (int idx = 0; idx < req.OrderedIds.Count; idx++) for (int idx = 0; idx < req.OrderedIds.Count; idx++)
@@ -1,18 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Budget.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class MeController : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
var sub = User.FindFirst("sub")?.Value;
var email = User.FindFirst("email")?.Value;
return Ok(new { sub, email });
}
}
+21 -8
View File
@@ -13,7 +13,13 @@ namespace Budget.Api.Controllers;
[Authorize] [Authorize]
public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase public class OutgosController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
{ {
private string UserId => User.FindFirst("sub")!.Value; private IActionResult? TryGetUserId(out string userId)
{
var id = User.GetUserId();
if (id is null) { userId = string.Empty; return Unauthorized(); }
userId = id;
return null;
}
private static OutgoDto ToDto(Outgo o, decimal totalMonthlyIncome) => new( private static OutgoDto ToDto(Outgo o, decimal totalMonthlyIncome) => new(
o.Id, o.BudgetId, o.Name, o.Category, o.Type, o.Frequency, o.Amount, o.Id, o.BudgetId, o.Name, o.Category, o.Type, o.Frequency, o.Amount,
@@ -34,7 +40,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
[HttpGet] [HttpGet]
public async Task<IActionResult> List(Guid budgetId) public async Task<IActionResult> List(Guid budgetId)
{ {
if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).OrderBy(o => o.SortOrder).ToListAsync(); var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).OrderBy(o => o.SortOrder).ToListAsync();
var monthlyIncome = await GetMonthlyIncomeAsync(budgetId); var monthlyIncome = await GetMonthlyIncomeAsync(budgetId);
return Ok(outgos.Select(o => ToDto(o, monthlyIncome))); return Ok(outgos.Select(o => ToDto(o, monthlyIncome)));
@@ -43,7 +50,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
[HttpPost] [HttpPost]
public async Task<IActionResult> Create(Guid budgetId, [FromBody] CreateOutgoRequest req) public async Task<IActionResult> Create(Guid budgetId, [FromBody] CreateOutgoRequest req)
{ {
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
var maxOrder = await db.Outgos.Where(o => o.BudgetId == budgetId).MaxAsync(o => (int?)o.SortOrder) ?? -1; var maxOrder = await db.Outgos.Where(o => o.BudgetId == budgetId).MaxAsync(o => (int?)o.SortOrder) ?? -1;
var outgo = new Outgo var outgo = new Outgo
{ {
@@ -67,7 +75,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
[HttpPut("{outgoId:guid}")] [HttpPut("{outgoId:guid}")]
public async Task<IActionResult> Update(Guid budgetId, Guid outgoId, [FromBody] UpdateOutgoRequest req) public async Task<IActionResult> Update(Guid budgetId, Guid outgoId, [FromBody] UpdateOutgoRequest req)
{ {
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
var outgo = await db.Outgos.FirstOrDefaultAsync(o => o.Id == outgoId && o.BudgetId == budgetId); var outgo = await db.Outgos.FirstOrDefaultAsync(o => o.Id == outgoId && o.BudgetId == budgetId);
if (outgo is null) return NotFound(); if (outgo is null) return NotFound();
outgo.Name = req.Name; outgo.Name = req.Name;
@@ -85,7 +94,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
[HttpDelete("{outgoId:guid}")] [HttpDelete("{outgoId:guid}")]
public async Task<IActionResult> Delete(Guid budgetId, Guid outgoId) public async Task<IActionResult> Delete(Guid budgetId, Guid outgoId)
{ {
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
var outgo = await db.Outgos.FirstOrDefaultAsync(o => o.Id == outgoId && o.BudgetId == budgetId); var outgo = await db.Outgos.FirstOrDefaultAsync(o => o.Id == outgoId && o.BudgetId == budgetId);
if (outgo is null) return NotFound(); if (outgo is null) return NotFound();
db.Outgos.Remove(outgo); db.Outgos.Remove(outgo);
@@ -96,7 +106,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
[HttpPut("order")] [HttpPut("order")]
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] ReorderOutgosRequest req) public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] ReorderOutgosRequest req)
{ {
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).ToListAsync(); var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).ToListAsync();
var lookup = outgos.ToDictionary(o => o.Id); var lookup = outgos.ToDictionary(o => o.Id);
for (int idx = 0; idx < req.OrderedIds.Count; idx++) for (int idx = 0; idx < req.OrderedIds.Count; idx++)
@@ -111,7 +122,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
[HttpGet("categories")] [HttpGet("categories")]
public async Task<IActionResult> Categories(Guid budgetId) public async Task<IActionResult> Categories(Guid budgetId)
{ {
if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
var cats = await db.Outgos var cats = await db.Outgos
.Where(o => o.BudgetId == budgetId && o.Category != null) .Where(o => o.BudgetId == budgetId && o.Category != null)
.Select(o => o.Category!) .Select(o => o.Category!)
@@ -124,7 +136,8 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
[HttpGet("payment-sources")] [HttpGet("payment-sources")]
public async Task<IActionResult> PaymentSources(Guid budgetId) public async Task<IActionResult> PaymentSources(Guid budgetId)
{ {
if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
var sources = await db.Outgos var sources = await db.Outgos
.Where(o => o.BudgetId == budgetId && o.PaymentSource != null) .Where(o => o.BudgetId == budgetId && o.PaymentSource != null)
.Select(o => o.PaymentSource!) .Select(o => o.PaymentSource!)
+18 -8
View File
@@ -13,15 +13,22 @@ namespace Budget.Api.Controllers;
[Authorize] [Authorize]
public class SharesController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase public class SharesController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
{ {
private string UserId => User.FindFirst("sub")!.Value; private IActionResult? TryGetUserId(out string userId)
{
var id = User.GetUserId();
if (id is null) { userId = string.Empty; return Unauthorized(); }
userId = id;
return null;
}
[HttpGet] [HttpGet]
public async Task<IActionResult> List(Guid budgetId) public async Task<IActionResult> List(Guid budgetId)
{ {
if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
var shares = await db.BudgetShares var shares = await db.BudgetShares
.Where(s => s.BudgetId == budgetId) .Where(s => s.BudgetId == budgetId)
.Select(s => new ShareDto(s.Id, s.SharedWithUserId, s.SharedWithEmail, s.Permission, s.IsPending, s.CreatedAt)) .Select(s => new ShareDto(s.Id, s.SharedWithEmail, s.Permission, s.IsPending, s.CreatedAt))
.ToListAsync(); .ToListAsync();
return Ok(shares); return Ok(shares);
} }
@@ -29,7 +36,8 @@ public class SharesController(AppDbContext db, BudgetAuthorizationService authz)
[HttpPost] [HttpPost]
public async Task<IActionResult> Add(Guid budgetId, [FromBody] CreateShareRequest req) public async Task<IActionResult> Add(Guid budgetId, [FromBody] CreateShareRequest req)
{ {
if (!await authz.IsOwnerAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.IsOwnerAsync(budgetId, userId)) return Forbid();
var existing = await db.BudgetShares var existing = await db.BudgetShares
.FirstOrDefaultAsync(s => s.BudgetId == budgetId && s.SharedWithEmail == req.Email); .FirstOrDefaultAsync(s => s.BudgetId == budgetId && s.SharedWithEmail == req.Email);
@@ -48,24 +56,26 @@ public class SharesController(AppDbContext db, BudgetAuthorizationService authz)
}; };
db.BudgetShares.Add(share); db.BudgetShares.Add(share);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new ShareDto(share.Id, share.SharedWithUserId, share.SharedWithEmail, share.Permission, share.IsPending, share.CreatedAt)); return Ok(new ShareDto(share.Id, share.SharedWithEmail, share.Permission, share.IsPending, share.CreatedAt));
} }
[HttpPut("{shareId:guid}")] [HttpPut("{shareId:guid}")]
public async Task<IActionResult> Update(Guid budgetId, Guid shareId, [FromBody] UpdateShareRequest req) public async Task<IActionResult> Update(Guid budgetId, Guid shareId, [FromBody] UpdateShareRequest req)
{ {
if (!await authz.IsOwnerAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.IsOwnerAsync(budgetId, userId)) return Forbid();
var share = await db.BudgetShares.FirstOrDefaultAsync(s => s.Id == shareId && s.BudgetId == budgetId); var share = await db.BudgetShares.FirstOrDefaultAsync(s => s.Id == shareId && s.BudgetId == budgetId);
if (share is null) return NotFound(); if (share is null) return NotFound();
share.Permission = req.Permission; share.Permission = req.Permission;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new ShareDto(share.Id, share.SharedWithUserId, share.SharedWithEmail, share.Permission, share.IsPending, share.CreatedAt)); return Ok(new ShareDto(share.Id, share.SharedWithEmail, share.Permission, share.IsPending, share.CreatedAt));
} }
[HttpDelete("{shareId:guid}")] [HttpDelete("{shareId:guid}")]
public async Task<IActionResult> Revoke(Guid budgetId, Guid shareId) public async Task<IActionResult> Revoke(Guid budgetId, Guid shareId)
{ {
if (!await authz.IsOwnerAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.IsOwnerAsync(budgetId, userId)) return Forbid();
var share = await db.BudgetShares.FirstOrDefaultAsync(s => s.Id == shareId && s.BudgetId == budgetId); var share = await db.BudgetShares.FirstOrDefaultAsync(s => s.Id == shareId && s.BudgetId == budgetId);
if (share is null) return NotFound(); if (share is null) return NotFound();
db.BudgetShares.Remove(share); db.BudgetShares.Remove(share);
@@ -13,12 +13,19 @@ namespace Budget.Api.Controllers;
[Authorize] [Authorize]
public class SummaryController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase public class SummaryController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
{ {
private string UserId => User.FindFirst("sub")!.Value; private IActionResult? TryGetUserId(out string userId)
{
var id = User.GetUserId();
if (id is null) { userId = string.Empty; return Unauthorized(); }
userId = id;
return null;
}
[HttpGet] [HttpGet]
public async Task<IActionResult> Get(Guid budgetId) public async Task<IActionResult> Get(Guid budgetId)
{ {
if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
var budget = await db.Budgets.FindAsync(budgetId); var budget = await db.Budgets.FindAsync(budgetId);
if (budget is null) return NotFound(); if (budget is null) return NotFound();
@@ -70,7 +77,8 @@ public class SummaryController(AppDbContext db, BudgetAuthorizationService authz
[HttpPut("tax-rate")] [HttpPut("tax-rate")]
public async Task<IActionResult> UpdateTaxRate(Guid budgetId, [FromBody] UpdateTaxRateRequest req) public async Task<IActionResult> UpdateTaxRate(Guid budgetId, [FromBody] UpdateTaxRateRequest req)
{ {
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); if (TryGetUserId(out var userId) is { } err) return err;
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
var budget = await db.Budgets.FindAsync(budgetId); var budget = await db.Budgets.FindAsync(budgetId);
if (budget is null) return NotFound(); if (budget is null) return NotFound();
budget.EffectiveTaxRate = req.EffectiveTaxRate; budget.EffectiveTaxRate = req.EffectiveTaxRate;
+5 -3
View File
@@ -1,7 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Budget.Api.DTOs; namespace Budget.Api.DTOs;
public record BudgetDto(Guid Id, string Name, string OwnerUserId, decimal EffectiveTaxRate, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); public record BudgetDto(Guid Id, string Name, decimal EffectiveTaxRate, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
public record CreateBudgetRequest(string Name); public record CreateBudgetRequest([Required][MaxLength(200)] string Name);
public record UpdateBudgetRequest(string Name); public record UpdateBudgetRequest([Required][MaxLength(200)] string Name);
+10 -3
View File
@@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using Budget.Api.Models; using Budget.Api.Models;
namespace Budget.Api.DTOs; namespace Budget.Api.DTOs;
@@ -12,8 +13,14 @@ public record IncomeDto(
decimal Annually, decimal Annually,
int SortOrder); int SortOrder);
public record CreateIncomeRequest(string Name, Frequency Frequency, decimal Amount); public record CreateIncomeRequest(
[Required][MaxLength(200)] string Name,
Frequency Frequency,
[Range(0, 9_999_999_999_999_999.99)] decimal Amount);
public record UpdateIncomeRequest(string Name, Frequency Frequency, decimal Amount); public record UpdateIncomeRequest(
[Required][MaxLength(200)] string Name,
Frequency Frequency,
[Range(0, 9_999_999_999_999_999.99)] decimal Amount);
public record ReorderIncomesRequest(List<Guid> OrderedIds); public record ReorderIncomesRequest([Required] List<Guid> OrderedIds);
+12 -11
View File
@@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using Budget.Api.Models; using Budget.Api.Models;
namespace Budget.Api.DTOs; namespace Budget.Api.DTOs;
@@ -18,21 +19,21 @@ public record OutgoDto(
int SortOrder); int SortOrder);
public record CreateOutgoRequest( public record CreateOutgoRequest(
string Name, [Required][MaxLength(200)] string Name,
string? Category, [MaxLength(100)] string? Category,
OutgoType Type, OutgoType Type,
Frequency Frequency, Frequency Frequency,
decimal Amount, [Range(0, 9_999_999_999_999_999.99)] decimal Amount,
string? PaymentSource, [MaxLength(100)] string? PaymentSource,
string? Notes); [MaxLength(1000)] string? Notes);
public record UpdateOutgoRequest( public record UpdateOutgoRequest(
string Name, [Required][MaxLength(200)] string Name,
string? Category, [MaxLength(100)] string? Category,
OutgoType Type, OutgoType Type,
Frequency Frequency, Frequency Frequency,
decimal Amount, [Range(0, 9_999_999_999_999_999.99)] decimal Amount,
string? PaymentSource, [MaxLength(100)] string? PaymentSource,
string? Notes); [MaxLength(1000)] string? Notes);
public record ReorderOutgosRequest(List<Guid> OrderedIds); public record ReorderOutgosRequest([Required] List<Guid> OrderedIds);
+3 -2
View File
@@ -1,9 +1,10 @@
using System.ComponentModel.DataAnnotations;
using Budget.Api.Models; using Budget.Api.Models;
namespace Budget.Api.DTOs; namespace Budget.Api.DTOs;
public record ShareDto(Guid Id, string? SharedWithUserId, string SharedWithEmail, SharePermission Permission, bool IsPending, DateTimeOffset CreatedAt); public record ShareDto(Guid Id, string SharedWithEmail, SharePermission Permission, bool IsPending, DateTimeOffset CreatedAt);
public record CreateShareRequest(string Email, SharePermission Permission); public record CreateShareRequest([Required][MaxLength(200)][EmailAddress] string Email, SharePermission Permission);
public record UpdateShareRequest(SharePermission Permission); public record UpdateShareRequest(SharePermission Permission);
+3 -1
View File
@@ -17,4 +17,6 @@ public record SummaryDto(
List<SummaryBreakdownItem> Breakdown, List<SummaryBreakdownItem> Breakdown,
PreTaxIncomeDto PreTaxIncome); PreTaxIncomeDto PreTaxIncome);
public record UpdateTaxRateRequest(decimal EffectiveTaxRate); public record UpdateTaxRateRequest(
[System.ComponentModel.DataAnnotations.Range(0.0, 0.9999, ErrorMessage = "Effective tax rate must be between 0 and 0.9999.")]
decimal EffectiveTaxRate);
+1 -1
View File
@@ -62,7 +62,7 @@ app.MapHealthChecks("/healthz", new HealthCheckOptions
[HealthStatus.Degraded] = StatusCodes.Status200OK, [HealthStatus.Degraded] = StatusCodes.Status200OK,
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable, [HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable,
} }
}); }).RequireAuthorization();
app.MapFallbackToFile("index.html"); app.MapFallbackToFile("index.html");
@@ -0,0 +1,10 @@
using System.Security.Claims;
namespace Budget.Api.Services;
public static class ClaimsPrincipalExtensions
{
/// <summary>Returns the sub claim, or null if absent.</summary>
public static string? GetUserId(this ClaimsPrincipal user) =>
user.FindFirst("sub")?.Value;
}
-2
View File
@@ -43,7 +43,6 @@ export interface OutgoDto {
export interface BudgetDto { export interface BudgetDto {
id: string; id: string;
name: string; name: string;
ownerUserId: string;
effectiveTaxRate: number; effectiveTaxRate: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -51,7 +50,6 @@ export interface BudgetDto {
export interface ShareDto { export interface ShareDto {
id: string; id: string;
sharedWithUserId: string | null;
sharedWithEmail: string; sharedWithEmail: string;
permission: SharePermission; permission: SharePermission;
isPending: boolean; isPending: boolean;