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:
@@ -13,9 +13,10 @@ public static class RecipeEndpoints
|
||||
|
||||
public static RouteGroupBuilder MapRecipeEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/", async (YesChefDb db, string? q) =>
|
||||
group.MapGet("/", async (YesChefDb db, HttpContext http, string? q) =>
|
||||
{
|
||||
var query = db.Recipes.AsQueryable();
|
||||
var familyId = http.User.GetFamilyId();
|
||||
var query = db.Recipes.Where(r => r.FamilyId == familyId);
|
||||
if (!string.IsNullOrWhiteSpace(q))
|
||||
query = query.Where(r => r.Title.Contains(q));
|
||||
|
||||
@@ -34,8 +35,10 @@ public static class RecipeEndpoints
|
||||
|
||||
group.MapPost("/", async (CreateRecipeRequest request, YesChefDb db, HttpContext http) =>
|
||||
{
|
||||
var familyId = http.User.GetFamilyId();
|
||||
var recipe = new Recipe
|
||||
{
|
||||
FamilyId = familyId,
|
||||
Title = request.Title,
|
||||
Description = request.Description,
|
||||
Instructions = request.Instructions,
|
||||
@@ -44,6 +47,7 @@ public static class RecipeEndpoints
|
||||
CreatedByUserId = http.User.GetUserId(),
|
||||
Ingredients = request.Ingredients.Select(i => new RecipeIngredient
|
||||
{
|
||||
FamilyId = familyId,
|
||||
Name = i.Name,
|
||||
Quantity = i.Quantity,
|
||||
SortOrder = i.SortOrder
|
||||
@@ -55,12 +59,14 @@ public static class RecipeEndpoints
|
||||
return Results.Created($"/api/recipes/{recipe.Id}", new { recipe.Id, recipe.Title });
|
||||
});
|
||||
|
||||
group.MapGet("/{id:int}", async (int id, YesChefDb db) =>
|
||||
group.MapGet("/{id:int}", async (int id, YesChefDb db, HttpContext http) =>
|
||||
{
|
||||
var familyId = http.User.GetFamilyId();
|
||||
var recipe = await db.Recipes
|
||||
.Where(r => r.Id == id && r.FamilyId == familyId)
|
||||
.Include(r => r.Ingredients.OrderBy(i => i.SortOrder))
|
||||
.Include(r => r.CreatedByUser)
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (recipe is null) return Results.NotFound();
|
||||
|
||||
@@ -80,7 +86,11 @@ public static class RecipeEndpoints
|
||||
|
||||
group.MapPut("/{id:int}", async (int id, UpdateRecipeRequest request, YesChefDb db, HttpContext http) =>
|
||||
{
|
||||
var recipe = await db.Recipes.Include(r => r.Ingredients).FirstOrDefaultAsync(r => r.Id == id);
|
||||
var familyId = http.User.GetFamilyId();
|
||||
var recipe = await db.Recipes
|
||||
.Where(r => r.Id == id && r.FamilyId == familyId)
|
||||
.Include(r => r.Ingredients)
|
||||
.FirstOrDefaultAsync();
|
||||
if (recipe is null) return Results.NotFound();
|
||||
|
||||
recipe.Title = request.Title;
|
||||
@@ -93,6 +103,7 @@ public static class RecipeEndpoints
|
||||
db.RecipeIngredients.RemoveRange(recipe.Ingredients);
|
||||
recipe.Ingredients = request.Ingredients.Select(i => new RecipeIngredient
|
||||
{
|
||||
FamilyId = familyId,
|
||||
Name = i.Name,
|
||||
Quantity = i.Quantity,
|
||||
SortOrder = i.SortOrder
|
||||
@@ -102,9 +113,10 @@ public static class RecipeEndpoints
|
||||
return Results.Ok(new { recipe.Id, recipe.Title });
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:int}", async (int id, YesChefDb db) =>
|
||||
group.MapDelete("/{id:int}", async (int id, YesChefDb db, HttpContext http) =>
|
||||
{
|
||||
var recipe = await db.Recipes.FindAsync(id);
|
||||
var familyId = http.User.GetFamilyId();
|
||||
var recipe = await db.Recipes.FirstOrDefaultAsync(r => r.Id == id && r.FamilyId == familyId);
|
||||
if (recipe is null) return Results.NotFound();
|
||||
|
||||
db.Recipes.Remove(recipe);
|
||||
|
||||
Reference in New Issue
Block a user