ASP.NET CORE MVC - Protecting User-Owned Data
Protecting User-Owned Product Data
Associate each newly created Product with the authenticated user who created it, filter Products by owner, and prevent users from editing or deleting records that belong to someone else.
By the end of this tutorial, every newly created Product will store an OwnerId, users will be able to view their own Products separately, and Edit/Delete operations will be authorized by record ownership.
Part 11 added ASP.NET Core Identity and protected Create, Edit and Delete with [Authorize]. That proves a user is logged in, but it does not yet prove that the user owns the Product being modified.
- Understand authentication versus ownership
- Add
OwnerIdto Product - Create and apply an ownership migration
- Inject
UserManager<IdentityUser> - Assign ownership during Create
- Add a My Products filter
- Protect Edit GET and POST by owner
- Protect Delete GET and POST by owner
- Hide management links for non-owners
- Test with two user accounts
- Inspect ownership in SQLite
- Compare complete final files
1. Open and Verify the Part 11 Project
Open the Xubuntu Terminal:
cd ~/aspnet-mvc-tutorial/ProductManagement
pwd
dotnet build
The expected path is:
/home/xubuntu/aspnet-mvc-tutorial/ProductManagement
Open the project:
code .
Continue only when Part 11 builds and registration/login already work.
2. Why [Authorize] Is Not Enough
Part 11 uses:
[Authorize]
This checks whether the user is authenticated.
But consider:
Part 12 adds a second rule:
Authenticated
AND
owns the requested Product
3. Add OwnerId to Product.cs
Step 3.1 — Open the Product model
code Models/Product.cs
Step 3.2 — Find the relationship properties near the bottom
You should already have:
public int? CategoryId { get; set; }
public Category? Category { get; set; }
Step 3.3 — Add OwnerId before the final closing brace
public string? OwnerId { get; set; }
Step 3.4 — Verify the end of Product.cs
public int? CategoryId { get; set; }
public Category? Category { get; set; }
public string? OwnerId { get; set; }
Products created before Part 12 have no owner value. Making the new property nullable allows the migration to preserve those existing rows. Newly created Products will receive an OwnerId automatically from the server.
4. Do Not Add OwnerId to ProductFormViewModel
Open the form ViewModel:
code ViewModels/ProductFormViewModel.cs
Do not add:
public string? OwnerId { get; set; }
The owner is not a value that the browser should choose.
Ownership must be assigned from the authenticated server-side user identity. Never trust a hidden form field or submitted request value to decide who owns a record.
5. Build Before Creating the Migration
Save Product.cs, then run:
dotnet build
Continue only when the build succeeds.
6. Create the AddProductOwner Migration
Because Product is an EF Core entity and its database shape changed, create a migration:
dotnet ef migrations add AddProductOwner
Verify:
dotnet ef migrations list
You should see:
InitialCreate
AddCategories
AddIdentity
AddProductOwner
7. Apply the Ownership Migration
dotnet ef database update
Inspect the Product schema:
sqlite3 ProductManagement.db
At the SQLite prompt:
.schema Products
.quit
The Products table should now contain an OwnerId column.
8. Add UserManager to ProductsController.cs
Step 8.1 — Open the controller
code Controllers/ProductsController.cs
Step 8.2 — Add the Identity namespace
At the top, add:
using Microsoft.AspNetCore.Identity;
Step 8.3 — Find the existing context field
You already have:
private readonly ApplicationDbContext _context;
Immediately below it, add:
private readonly UserManager<IdentityUser> _userManager;
Step 8.4 — Find the constructor
It currently resembles:
public ProductsController(ApplicationDbContext context)
{
_context = context;
}
Step 8.5 — Replace the constructor
public ProductsController(
ApplicationDbContext context,
UserManager<IdentityUser> userManager)
{
_context = context;
_userManager = userManager;
}
9. Understand GetUserId(User)
ASP.NET Core Identity's UserManager can read the current user's ID from the authenticated ClaimsPrincipal:
var userId = _userManager.GetUserId(User);
The returned value corresponds to the Identity user's ID stored in:
AspNetUsers.Id
10. Assign OwnerId During Create
Step 10.1 — Keep ProductsController.cs open
Step 10.2 — Find the Create POST action
Inside it, find the Product construction:
var product = new Product
{
Name = viewModel.Name,
Description = viewModel.Description,
Price = viewModel.Price,
Quantity = viewModel.Quantity,
CategoryId = viewModel.CategoryId
};
Step 10.3 — Get the current user ID before creating Product
Immediately before var product = new Product, add:
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
Step 10.4 — Add OwnerId to the Product mapping
Replace the Product construction with:
var product = new Product
{
Name = viewModel.Name,
Description = viewModel.Description,
Price = viewModel.Price,
Quantity = viewModel.Quantity,
CategoryId = viewModel.CategoryId,
OwnerId = userId
};
Step 10.5 — Verify the ownership flow
11. Add My Products Filtering to ProductIndexViewModel.cs
Step 11.1 — Open the ViewModel
code ViewModels/ProductIndexViewModel.cs
Step 11.2 — Add a Mine property
Place this near the other filter properties:
public bool Mine { get; set; }
The relevant section should resemble:
public string? Search { get; set; }
public int? CategoryId { get; set; }
public decimal? MaxPrice { get; set; }
public bool Mine { get; set; }
public string? SortOrder { get; set; }
12. Update the Index Action to Support My Products
Step 12.1 — Open ProductsController.cs
Step 12.2 — Find the Index method signature
It currently ends with string? sortOrder. Replace the signature with:
public async Task<IActionResult> Index(
string? search,
int? categoryId,
decimal? maxPrice,
string? sortOrder,
bool mine = false)
Step 12.3 — Add ownership filtering after the existing price filter
Find:
if (maxPrice.HasValue)
{
products = products.Where(
p => p.Price <= maxPrice.Value);
}
Immediately after it, add:
if (mine)
{
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
products = products.Where(
p => p.OwnerId == userId);
}
Step 12.4 — Store Mine in the ViewModel
Inside the new ProductIndexViewModel initializer, add:
Mine = mine,
For example:
Search = search,
CategoryId = categoryId,
MaxPrice = maxPrice,
Mine = mine,
SortOrder = sortOrder,
13. Preserve My Products When Sorting
The sorting links are generated in Index.cshtml, so we will pass the new filter through those links.
Open:
code Views/Products/Index.cshtml
Find the Name sorting link and add:
asp-route-mine="@Model.Mine"
Do the same for the Price sorting link.
For example:
<a asp-action="Index"
asp-route-search="@Model.Search"
asp-route-categoryId="@Model.CategoryId"
asp-route-maxPrice="@Model.MaxPrice"
asp-route-mine="@Model.Mine"
asp-route-sortOrder="@Model.NameSort">
Name
</a>
14. Preserve Mine When Applying Filters
Still in:
Views/Products/Index.cshtml
Find the hidden sortOrder field near the beginning of the filter form.
Immediately after it, add:
<input type="hidden"
name="mine"
value="@Model.Mine.ToString().ToLower()" />
15. Add All Products and My Products Links
In Views/Products/Index.cshtml, find the existing Create New Product section.
Immediately after it, add:
<p>
<a asp-action="Index"
class="btn btn-outline-primary">
All Products
</a>
@if (User.Identity?.IsAuthenticated == true)
{
<a asp-action="Index"
asp-route-mine="true"
class="btn btn-outline-primary">
My Products
</a>
}
</p>
Anonymous users see only the public Product catalogue. Logged-in users can switch to their own Product list.
16. Add Ownership Check to Edit GET
Step 16.1 — Open ProductsController.cs
Step 16.2 — Find Edit GET
After the existing Product-not-found check:
if (product == null)
{
return NotFound();
}
add:
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
if (product.OwnerId != userId)
{
return Forbid();
}
The authorization flow becomes:
17. Add Ownership Check to Edit POST
Step 17.1 — Find Edit POST
Find the section where the existing Product is loaded:
var product = await _context.Products
.FindAsync(id);
if (product == null)
{
return NotFound();
}
Step 17.2 — Add the owner check immediately after it
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
if (product.OwnerId != userId)
{
return Forbid();
}
Only after this ownership test should the controller copy the ViewModel values into the Product.
18. Add Ownership Check to Delete GET
Step 18.1 — Find Delete GET
After:
if (product == null)
{
return NotFound();
}
add:
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
if (product.OwnerId != userId)
{
return Forbid();
}
19. Add Ownership Check to Delete POST
Step 19.1 — Find DeleteConfirmed()
Replace the action with:
[Authorize]
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
var product = await _context.Products
.FindAsync(id);
if (product == null)
{
return NotFound();
}
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
if (product.OwnerId != userId)
{
return Forbid();
}
_context.Products.Remove(product);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
Even if the UI hides a Delete link, a user can manually construct /Products/Delete/10 or submit a POST request. The controller must independently verify ownership.
20. Hide Edit/Delete Links for Non-Owners
Step 20.1 — Open Index.cshtml
code Views/Products/Index.cshtml
Step 20.2 — Add Identity injection at the top
Immediately below the @model directive, add:
@using Microsoft.AspNetCore.Identity
@inject UserManager<IdentityUser> UserManager
Step 20.3 — Find the Action cell inside foreach
Replace the current action cell with:
<td>
<a asp-action="Details"
asp-route-id="@product.Id">Details</a>
@if (User.Identity?.IsAuthenticated == true
&& product.OwnerId == UserManager.GetUserId(User))
{
<text> | </text>
<a asp-action="Edit"
asp-route-id="@product.Id">Edit</a>
<text> | </text>
<a asp-action="Delete"
asp-route-id="@product.Id">Delete</a>
}
</td>
This makes the page clearer by hiding management links for non-owners. It is only a convenience. The real security remains in the controller checks from Sections 16–19.
21. What Happens to Products Created Before Part 12?
Those Products have:
OwnerId = NULL
They remain visible in All Products, but ordinary users cannot Edit or Delete them because no user ID matches a null OwnerId.
Keeping old rows demonstrates a realistic migration scenario. Part 13 will introduce an Administrator role, which provides a natural place to add privileged management of records that ordinary users cannot own or modify.
22. Build the Project
Save the changed files:
Models/Product.cs
ViewModels/ProductIndexViewModel.cs
Controllers/ProductsController.cs
Views/Products/Index.cshtml
Then run:
dotnet build
Continue only when the project builds successfully.
23. Test with User A
dotnet run
Log in as User A and create a new Product.
Then open:
/Products?mine=true
The new Product should appear in My Products.
User A should be able to Edit and Delete that Product.
24. Register User B
Log out and register a second account.
Log in as User B and open:
/Products
User B can still view User A's Product details because the catalogue remains public.
However, User B should not see Edit or Delete links for User A's Product.
25. Test Direct Unauthorized Edit
While logged in as User B, manually enter the Edit URL for User A's Product:
/Products/Edit/ID
Replace ID with User A's Product ID.
The server should reject access with:
Forbid()
26. Test Direct Unauthorized Delete
While still logged in as User B, manually open:
/Products/Delete/ID
The request should also be forbidden.
27. Verify Ownership in SQLite
Stop the app if necessary and open SQLite:
sqlite3 ProductManagement.db
View users:
SELECT Id, UserName
FROM AspNetUsers;
View Product ownership:
SELECT Id, Name, OwnerId
FROM Products;
Join Products to Identity users:
SELECT
Products.Id,
Products.Name,
AspNetUsers.UserName
FROM Products
LEFT JOIN AspNetUsers
ON Products.OwnerId = AspNetUsers.Id;
Exit:
.quit
28. Ownership Architecture
29. Troubleshooting
UserManager cannot be found
Open ProductsController.cs and confirm:
using Microsoft.AspNetCore.Identity;OwnerId does not exist in SQLite
Confirm the migration was created and applied:
dotnet ef migrations list
dotnet ef database updateNew Products have NULL OwnerId
Open the Create POST action and confirm the Product mapping contains:
OwnerId = userIdUser can still access somebody else's Edit page
Check both Edit GET and Edit POST. Both must compare:
product.OwnerId != userIdand return Forbid() when the IDs do not match.
My Products redirects to Login
This is expected when the visitor is anonymous. The mine=true filter requires a current authenticated user ID.
30. Hands-On Exercise
- Create two user accounts.
- Log in as User A and create two Products.
- Open My Products and confirm both appear.
- Log out and log in as User B.
- Create one Product as User B.
- Confirm My Products shows only User B's Product.
- Confirm All Products still shows the public catalogue.
- Try to edit one of User A's Products using its URL.
- Confirm access is forbidden.
- Verify all OwnerId values directly in SQLite.
31. Knowledge Check
- Why is
[Authorize]alone insufficient for user-owned records? - What does
OwnerIdstore? - Why is OwnerId not included in
ProductFormViewModel? - How is the current user ID obtained?
- Why is OwnerId assigned during the Create POST action?
- What does
Forbid()mean in the ownership checks? - Why must Edit GET and Edit POST both check ownership?
- Why must Delete POST independently check ownership?
- What does the
mine=truefilter do? - Why are Edit/Delete links hidden for non-owners even though controller checks already exist?
Show suggested answers
- Because it only proves the user is logged in, not that the user owns the requested Product.
- The ASP.NET Core Identity user ID of the Product owner.
- Because ownership is server-controlled and must not be chosen by the client.
- With
_userManager.GetUserId(User). - So ownership is established from the authenticated user at the moment the record is created.
- The user is authenticated but is not permitted to access that resource.
- Both displaying the edit form and applying the update are protected operations.
- Because a malicious user can submit a POST directly without using the visible UI.
- It restricts the Index query to Products whose OwnerId matches the current user.
- To provide a clear user interface; security still comes from server-side authorization.
32. Part 12 Summary
- added
OwnerIdto Product; - created and applied the
AddProductOwnermigration; - injected
UserManager<IdentityUser>; - obtained the current user ID with
GetUserId(User); - assigned ownership during Product creation;
- added a My Products filter;
- protected Edit GET and POST by ownership;
- protected Delete GET and POST by ownership;
- hid Edit/Delete links for non-owners; and
- verified ownership directly in SQLite.
Appendix — Full Code for Final Verification
Use this appendix after completing Part 12. Compare the complete files modified in this tutorial with your project.
Appendix A — Models/Product.cs
using System.ComponentModel.DataAnnotations;
namespace ProductManagement.Models;
public class Product
{
public int Id { get; set; }
[Required(ErrorMessage = "Product name is required.")]
[StringLength(
100,
ErrorMessage = "Product name cannot exceed 100 characters.")]
public string Name { get; set; } = string.Empty;
[StringLength(
500,
ErrorMessage = "Description cannot exceed 500 characters.")]
public string? Description { get; set; }
[Range(
0.01,
1000000,
ErrorMessage = "Price must be greater than zero.")]
public decimal Price { get; set; }
[Range(
0,
1000000,
ErrorMessage = "Quantity cannot be negative.")]
public int Quantity { get; set; }
public int? CategoryId { get; set; }
public Category? Category { get; set; }
public string? OwnerId { get; set; }
}
Appendix B — ViewModels/ProductIndexViewModel.cs
using Microsoft.AspNetCore.Mvc.Rendering;
using ProductManagement.Models;
namespace ProductManagement.ViewModels;
public class ProductIndexViewModel
{
public IEnumerable<Product> Products { get; set; }
= new List<Product>();
public string? Search { get; set; }
public int? CategoryId { get; set; }
public decimal? MaxPrice { get; set; }
public bool Mine { get; set; }
public string? SortOrder { get; set; }
public string? NameSort { get; set; }
public string? PriceSort { get; set; }
public IEnumerable<SelectListItem> Categories { get; set; }
= new List<SelectListItem>();
}
Appendix C — Controllers/ProductsController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Data;
using ProductManagement.Models;
using ProductManagement.ViewModels;
namespace ProductManagement.Controllers;
public class ProductsController : Controller
{
private readonly ApplicationDbContext _context;
private readonly UserManager<IdentityUser> _userManager;
public ProductsController(
ApplicationDbContext context,
UserManager<IdentityUser> userManager)
{
_context = context;
_userManager = userManager;
}
public async Task<IActionResult> Index(
string? search,
int? categoryId,
decimal? maxPrice,
string? sortOrder,
bool mine = false)
{
var products = _context.Products
.Include(p => p.Category)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
{
products = products.Where(
p => p.Name.Contains(search));
}
if (categoryId.HasValue)
{
products = products.Where(
p => p.CategoryId == categoryId.Value);
}
if (maxPrice.HasValue)
{
products = products.Where(
p => p.Price <= maxPrice.Value);
}
if (mine)
{
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
products = products.Where(
p => p.OwnerId == userId);
}
products = sortOrder switch
{
"name_desc" =>
products.OrderByDescending(p => p.Name),
"price" =>
products.OrderBy(p => p.Price),
"price_desc" =>
products.OrderByDescending(p => p.Price),
_ =>
products.OrderBy(p => p.Name)
};
var viewModel = new ProductIndexViewModel
{
Products = await products.ToListAsync(),
Search = search,
CategoryId = categoryId,
MaxPrice = maxPrice,
Mine = mine,
SortOrder = sortOrder,
NameSort =
sortOrder == "name_desc"
? ""
: "name_desc",
PriceSort =
sortOrder == "price"
? "price_desc"
: "price",
Categories = await _context.Categories
.OrderBy(c => c.Name)
.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = c.Name,
Selected = c.Id == categoryId
})
.ToListAsync()
};
return View(viewModel);
}
public async Task<IActionResult> Details(int? id)
{
if (id == null)
{
return NotFound();
}
var product = await _context.Products
.Include(p => p.Category)
.FirstOrDefaultAsync(p => p.Id == id);
if (product == null)
{
return NotFound();
}
return View(product);
}
[Authorize]
[HttpGet]
public async Task<IActionResult> Create()
{
var viewModel = new ProductFormViewModel
{
Categories = await GetCategoryItemsAsync()
};
return View(viewModel);
}
[Authorize]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(
ProductFormViewModel viewModel)
{
if (!ModelState.IsValid)
{
viewModel.Categories =
await GetCategoryItemsAsync(
viewModel.CategoryId);
return View(viewModel);
}
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
var product = new Product
{
Name = viewModel.Name,
Description = viewModel.Description,
Price = viewModel.Price,
Quantity = viewModel.Quantity,
CategoryId = viewModel.CategoryId,
OwnerId = userId
};
_context.Products.Add(product);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
[Authorize]
[HttpGet]
public async Task<IActionResult> Edit(int? id)
{
if (id == null)
{
return NotFound();
}
var product = await _context.Products
.FindAsync(id);
if (product == null)
{
return NotFound();
}
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
if (product.OwnerId != userId)
{
return Forbid();
}
var viewModel = new ProductFormViewModel
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Price = product.Price,
Quantity = product.Quantity,
CategoryId = product.CategoryId,
Categories =
await GetCategoryItemsAsync(
product.CategoryId)
};
return View(viewModel);
}
[Authorize]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(
int id,
ProductFormViewModel viewModel)
{
if (id != viewModel.Id)
{
return NotFound();
}
if (!ModelState.IsValid)
{
viewModel.Categories =
await GetCategoryItemsAsync(
viewModel.CategoryId);
return View(viewModel);
}
var product = await _context.Products
.FindAsync(id);
if (product == null)
{
return NotFound();
}
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
if (product.OwnerId != userId)
{
return Forbid();
}
product.Name = viewModel.Name;
product.Description = viewModel.Description;
product.Price = viewModel.Price;
product.Quantity = viewModel.Quantity;
product.CategoryId = viewModel.CategoryId;
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
[Authorize]
[HttpGet]
public async Task<IActionResult> Delete(int? id)
{
if (id == null)
{
return NotFound();
}
var product = await _context.Products
.Include(p => p.Category)
.FirstOrDefaultAsync(p => p.Id == id);
if (product == null)
{
return NotFound();
}
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
if (product.OwnerId != userId)
{
return Forbid();
}
return View(product);
}
[Authorize]
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
var product = await _context.Products
.FindAsync(id);
if (product == null)
{
return NotFound();
}
var userId = _userManager.GetUserId(User);
if (userId == null)
{
return Challenge();
}
if (product.OwnerId != userId)
{
return Forbid();
}
_context.Products.Remove(product);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
private async Task<List<SelectListItem>>
GetCategoryItemsAsync(int? selectedId = null)
{
return await _context.Categories
.OrderBy(c => c.Name)
.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = c.Name,
Selected = c.Id == selectedId
})
.ToListAsync();
}
private bool ProductExists(int id)
{
return _context.Products.Any(
p => p.Id == id);
}
}
Appendix D — Views/Products/Index.cshtml
@model ProductManagement.ViewModels.ProductIndexViewModel
@using Microsoft.AspNetCore.Identity
@inject UserManager<IdentityUser> UserManager
@{
ViewData["Title"] = "Products";
}
<h1>Products</h1>
@if (User.Identity?.IsAuthenticated == true)
{
<p>
<a asp-action="Create"
class="btn btn-primary">
Create New Product
</a>
</p>
}
<p>
<a asp-action="Index"
class="btn btn-outline-primary">
All Products
</a>
@if (User.Identity?.IsAuthenticated == true)
{
<a asp-action="Index"
asp-route-mine="true"
class="btn btn-outline-primary">
My Products
</a>
}
</p>
<form asp-action="Index"
method="get"
class="row g-3 mb-4">
<input type="hidden"
name="sortOrder"
value="@Model.SortOrder" />
<input type="hidden"
name="mine"
value="@Model.Mine.ToString().ToLower()" />
<div class="col-md-4">
<label for="search"
class="form-label">Search</label>
<input type="text"
id="search"
name="search"
value="@Model.Search"
class="form-control"
placeholder="Product name" />
</div>
<div class="col-md-3">
<label for="categoryId"
class="form-label">Category</label>
<select id="categoryId"
name="categoryId"
class="form-select"
asp-items="Model.Categories">
<option value="">All Categories</option>
</select>
</div>
<div class="col-md-3">
<label for="maxPrice"
class="form-label">Maximum Price</label>
<input type="number"
id="maxPrice"
name="maxPrice"
value="@Model.MaxPrice"
class="form-control"
min="0"
step="0.01" />
</div>
<div class="col-md-2 d-flex align-items-end">
<button type="submit"
class="btn btn-primary w-100">
Apply
</button>
</div>
</form>
<p>
<a asp-action="Index"
asp-route-mine="@Model.Mine"
class="btn btn-outline-secondary">
Clear Search/Filters
</a>
</p>
@if (!Model.Products.Any())
{
<p>No products match the current view and filters.</p>
}
else
{
<table class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>
<a asp-action="Index"
asp-route-search="@Model.Search"
asp-route-categoryId="@Model.CategoryId"
asp-route-maxPrice="@Model.MaxPrice"
asp-route-mine="@Model.Mine"
asp-route-sortOrder="@Model.NameSort">
Name
</a>
</th>
<th>
<a asp-action="Index"
asp-route-search="@Model.Search"
asp-route-categoryId="@Model.CategoryId"
asp-route-maxPrice="@Model.MaxPrice"
asp-route-mine="@Model.Mine"
asp-route-sortOrder="@Model.PriceSort">
Price
</a>
</th>
<th>Quantity</th>
<th>Category</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@foreach (var product in Model.Products)
{
<tr>
<td>@product.Id</td>
<td>@product.Name</td>
<td>RM @product.Price.ToString("N2")</td>
<td>@product.Quantity</td>
<td>@(product.Category?.Name ?? "Unassigned")</td>
<td>
<a asp-action="Details"
asp-route-id="@product.Id">
Details
</a>
@if (User.Identity?.IsAuthenticated == true
&& product.OwnerId
== UserManager.GetUserId(User))
{
<text> | </text>
<a asp-action="Edit"
asp-route-id="@product.Id">
Edit
</a>
<text> | </text>
<a asp-action="Delete"
asp-route-id="@product.Id">
Delete
</a>
}
</td>
</tr>
}
</tbody>
</table>
}
Appendix E — Final Verification Commands
cd ~/aspnet-mvc-tutorial/ProductManagement
dotnet build
dotnet ef migrations list
dotnet ef database update
dotnet run
Browser checks:
/Products
/Products?mine=true
/Products/Create
/Products/Edit/1
/Products/Delete/1
SQLite checks:
sqlite3 ProductManagement.db
SELECT Id, UserName
FROM AspNetUsers;
SELECT Id, Name, OwnerId
FROM Products;
SELECT
Products.Name,
AspNetUsers.UserName
FROM Products
LEFT JOIN AspNetUsers
ON Products.OwnerId = AspNetUsers.Id;
.quit
If new Products receive the logged-in user's OwnerId, My Products returns only the current user's records, owners can Edit/Delete their own Products, and another authenticated user receives Forbidden when trying to modify those records directly, Part 12 is complete.
Part 13 will create an Administrator role, assign users to roles, use [Authorize(Roles = "Admin")], and extend the ownership rule so Administrators can manage all Products while normal users remain limited to their own records.