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:
Josh Rogers
2026-05-07 23:05:23 -05:00
parent 7c1cfd62e6
commit 9b2db931ee
25 changed files with 1057 additions and 90 deletions
@@ -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);