Scope all data access by FamilyId for multi-tenant isolation
Adds FamilyMembership join (UserId, FamilyId, Role) and a non-null FamilyId FK on Store, ShoppingList, ShoppingListItem, Recipe, and RecipeIngredient. FamilyId is denormalized on items/ingredients so the tenant filter is a single column predicate without joins. Store name uniqueness is now scoped per family. JWT issuance stamps a family_id claim; ClaimsPrincipalExtensions exposes GetFamilyId(). Register validates the supplied invite code against Family.InviteCode (replacing the env-var equality check) and writes a FamilyMembership row. OnTokenValidated rejects requests whose user has been removed from the claimed family since login. Every endpoint filters by FamilyId on read and stamps it on write. Cross-family storeId references on list create/update return 400. The SignalR hub verifies list ownership on JoinList and uses a per-family overview group, so cross-tenant fan-out is structurally impossible. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using YesChef.Api.Auth;
|
||||
using YesChef.Api.Data;
|
||||
using YesChef.Api.Entities;
|
||||
|
||||
@@ -11,20 +12,32 @@ public static class StoreEndpoints
|
||||
|
||||
public static RouteGroupBuilder MapStoreEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/", async (YesChefDb db) =>
|
||||
await db.Stores.OrderBy(s => s.SortOrder).ThenBy(s => s.Name).ToListAsync());
|
||||
|
||||
group.MapPost("/", async (CreateStoreRequest request, YesChefDb db) =>
|
||||
group.MapGet("/", async (YesChefDb db, HttpContext http) =>
|
||||
{
|
||||
var store = new Store { Name = request.Name, SortOrder = request.SortOrder };
|
||||
var familyId = http.User.GetFamilyId();
|
||||
return await db.Stores
|
||||
.Where(s => s.FamilyId == familyId)
|
||||
.OrderBy(s => s.SortOrder).ThenBy(s => s.Name)
|
||||
.ToListAsync();
|
||||
});
|
||||
|
||||
group.MapPost("/", async (CreateStoreRequest request, YesChefDb db, HttpContext http) =>
|
||||
{
|
||||
var store = new Store
|
||||
{
|
||||
FamilyId = http.User.GetFamilyId(),
|
||||
Name = request.Name,
|
||||
SortOrder = request.SortOrder,
|
||||
};
|
||||
db.Stores.Add(store);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Created($"/api/stores/{store.Id}", store);
|
||||
});
|
||||
|
||||
group.MapPut("/{id:int}", async (int id, UpdateStoreRequest request, YesChefDb db) =>
|
||||
group.MapPut("/{id:int}", async (int id, UpdateStoreRequest request, YesChefDb db, HttpContext http) =>
|
||||
{
|
||||
var store = await db.Stores.FindAsync(id);
|
||||
var familyId = http.User.GetFamilyId();
|
||||
var store = await db.Stores.FirstOrDefaultAsync(s => s.Id == id && s.FamilyId == familyId);
|
||||
if (store is null) return Results.NotFound();
|
||||
|
||||
store.Name = request.Name;
|
||||
@@ -33,14 +46,15 @@ public static class StoreEndpoints
|
||||
return Results.Ok(store);
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:int}", async (int id, YesChefDb db) =>
|
||||
group.MapDelete("/{id:int}", async (int id, YesChefDb db, HttpContext http) =>
|
||||
{
|
||||
var hasLists = await db.ShoppingLists.AnyAsync(l => l.StoreId == id);
|
||||
if (hasLists) return Results.BadRequest(new { error = "Store has shopping lists. Remove them first." });
|
||||
|
||||
var store = await db.Stores.FindAsync(id);
|
||||
var familyId = http.User.GetFamilyId();
|
||||
var store = await db.Stores.FirstOrDefaultAsync(s => s.Id == id && s.FamilyId == familyId);
|
||||
if (store is null) return Results.NotFound();
|
||||
|
||||
var hasLists = await db.ShoppingLists.AnyAsync(l => l.StoreId == id && l.FamilyId == familyId);
|
||||
if (hasLists) return Results.BadRequest(new { error = "Store has shopping lists. Remove them first." });
|
||||
|
||||
db.Stores.Remove(store);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.NoContent();
|
||||
|
||||
Reference in New Issue
Block a user