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:
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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!)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user