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:
@@ -0,0 +1,13 @@
|
||||
using YesChef.Api.Data;
|
||||
|
||||
namespace YesChef.Api.IntegrationTests.Builders;
|
||||
|
||||
internal static class BuilderDefaults
|
||||
{
|
||||
/// <summary>
|
||||
/// When a builder doesn't specify a family, default to the bootstrap
|
||||
/// family seeded by Program.cs. Test DBs always have at least one.
|
||||
/// </summary>
|
||||
public static async Task<int> DefaultFamilyIdAsync(YesChefDb db) =>
|
||||
await db.Families.OrderBy(f => f.Id).Select(f => f.Id).FirstAsync();
|
||||
}
|
||||
@@ -11,6 +11,7 @@ public sealed class RecipeBuilder
|
||||
private int? _servings;
|
||||
private string? _sourceUrl;
|
||||
private int? _createdByUserId;
|
||||
private int? _familyId;
|
||||
private readonly List<RecipeIngredient> _ingredients = [];
|
||||
|
||||
public RecipeBuilder Titled(string title) { _title = title; return this; }
|
||||
@@ -20,6 +21,8 @@ public sealed class RecipeBuilder
|
||||
public RecipeBuilder SourcedFrom(string url) { _sourceUrl = url; return this; }
|
||||
public RecipeBuilder CreatedBy(User user) { _createdByUserId = user.Id; return this; }
|
||||
public RecipeBuilder CreatedBy(int userId) { _createdByUserId = userId; return this; }
|
||||
public RecipeBuilder ForFamily(int familyId) { _familyId = familyId; return this; }
|
||||
public RecipeBuilder ForFamily(Family family) { _familyId = family.Id; return this; }
|
||||
|
||||
public RecipeBuilder WithIngredient(string name, string? quantity = null, int sortOrder = 0)
|
||||
{
|
||||
@@ -27,12 +30,16 @@ public sealed class RecipeBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
public Recipe Build()
|
||||
public async Task<Recipe> PersistAsync(YesChefDb db)
|
||||
{
|
||||
if (_createdByUserId is null) throw new InvalidOperationException("Recipe requires a creator. Call CreatedBy().");
|
||||
|
||||
return new Recipe
|
||||
var familyId = _familyId ?? await BuilderDefaults.DefaultFamilyIdAsync(db);
|
||||
foreach (var ing in _ingredients) ing.FamilyId = familyId;
|
||||
|
||||
var recipe = new Recipe
|
||||
{
|
||||
FamilyId = familyId,
|
||||
Title = _title,
|
||||
Description = _description,
|
||||
Instructions = _instructions,
|
||||
@@ -41,11 +48,6 @@ public sealed class RecipeBuilder
|
||||
CreatedByUserId = _createdByUserId.Value,
|
||||
Ingredients = _ingredients
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<Recipe> PersistAsync(YesChefDb db)
|
||||
{
|
||||
var recipe = Build();
|
||||
db.Recipes.Add(recipe);
|
||||
await db.SaveChangesAsync();
|
||||
return recipe;
|
||||
|
||||
@@ -8,14 +8,17 @@ public sealed class ShoppingListBuilder
|
||||
private string _name = $"List-{Guid.NewGuid():N}"[..20];
|
||||
private int? _storeId;
|
||||
private int? _createdByUserId;
|
||||
private int? _familyId;
|
||||
private bool _archived;
|
||||
private readonly List<ShoppingListItem> _items = [];
|
||||
|
||||
public ShoppingListBuilder Named(string name) { _name = name; return this; }
|
||||
public ShoppingListBuilder ForStore(Store store) { _storeId = store.Id; return this; }
|
||||
public ShoppingListBuilder ForStore(Store store) { _storeId = store.Id; _familyId ??= store.FamilyId; return this; }
|
||||
public ShoppingListBuilder ForStore(int storeId) { _storeId = storeId; return this; }
|
||||
public ShoppingListBuilder CreatedBy(User user) { _createdByUserId = user.Id; return this; }
|
||||
public ShoppingListBuilder CreatedBy(int userId) { _createdByUserId = userId; return this; }
|
||||
public ShoppingListBuilder ForFamily(int familyId) { _familyId = familyId; return this; }
|
||||
public ShoppingListBuilder ForFamily(Family family) { _familyId = family.Id; return this; }
|
||||
public ShoppingListBuilder Archived() { _archived = true; return this; }
|
||||
|
||||
public ShoppingListBuilder WithItem(string name, bool isChecked = false, int sortOrder = 0)
|
||||
@@ -24,24 +27,23 @@ public sealed class ShoppingListBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
public ShoppingList Build()
|
||||
public async Task<ShoppingList> PersistAsync(YesChefDb db)
|
||||
{
|
||||
if (_storeId is null) throw new InvalidOperationException("ShoppingList requires a Store. Call ForStore().");
|
||||
if (_createdByUserId is null) throw new InvalidOperationException("ShoppingList requires a creator. Call CreatedBy().");
|
||||
|
||||
return new ShoppingList
|
||||
var familyId = _familyId ?? await BuilderDefaults.DefaultFamilyIdAsync(db);
|
||||
foreach (var item in _items) item.FamilyId = familyId;
|
||||
|
||||
var list = new ShoppingList
|
||||
{
|
||||
FamilyId = familyId,
|
||||
Name = _name,
|
||||
StoreId = _storeId.Value,
|
||||
CreatedByUserId = _createdByUserId.Value,
|
||||
IsArchived = _archived,
|
||||
Items = _items
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ShoppingList> PersistAsync(YesChefDb db)
|
||||
{
|
||||
var list = Build();
|
||||
db.ShoppingLists.Add(list);
|
||||
await db.SaveChangesAsync();
|
||||
return list;
|
||||
|
||||
@@ -7,15 +7,17 @@ public sealed class StoreBuilder
|
||||
{
|
||||
private string _name = $"Store-{Guid.NewGuid():N}"[..20];
|
||||
private int _sortOrder;
|
||||
private int? _familyId;
|
||||
|
||||
public StoreBuilder Named(string name) { _name = name; return this; }
|
||||
public StoreBuilder WithSortOrder(int sortOrder) { _sortOrder = sortOrder; return this; }
|
||||
|
||||
public Store Build() => new() { Name = _name, SortOrder = _sortOrder };
|
||||
public StoreBuilder ForFamily(int familyId) { _familyId = familyId; return this; }
|
||||
public StoreBuilder ForFamily(Family family) { _familyId = family.Id; return this; }
|
||||
|
||||
public async Task<Store> PersistAsync(YesChefDb db)
|
||||
{
|
||||
var store = Build();
|
||||
var familyId = _familyId ?? await BuilderDefaults.DefaultFamilyIdAsync(db);
|
||||
var store = new Store { FamilyId = familyId, Name = _name, SortOrder = _sortOrder };
|
||||
db.Stores.Add(store);
|
||||
await db.SaveChangesAsync();
|
||||
return store;
|
||||
|
||||
Reference in New Issue
Block a user