ASP.NET CORE MVC - Protecting User-Owned Data

ASP.NET CORE MVC TUTORIAL SERIES · PART 12

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.

Objective

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.

Starting point

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.

In this tutorial
  1. Understand authentication versus ownership
  2. Add OwnerId to Product
  3. Create and apply an ownership migration
  4. Inject UserManager<IdentityUser>
  5. Assign ownership during Create
  6. Add a My Products filter
  7. Protect Edit GET and POST by owner
  8. Protect Delete GET and POST by owner
  9. Hide management links for non-owners
  10. Test with two user accounts
  11. Inspect ownership in SQLite
  12. 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 .
Checkpoint

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:

User A creates Product 10 ↓ User B logs in ↓ User B manually opens /Products/Edit/10 ↓ [Authorize] ↓ User B is authenticated ↓ Without ownership checking, the edit could be allowed

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; }
Why is OwnerId nullable?

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.

ProductFormViewModel Name Description Price Quantity CategoryId ↓ User controls these values OwnerId ↓ Server controls this value
Security principle

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
Logged-in user ↓ User ClaimsPrincipal ↓ _userManager.GetUserId(User) ↓ Identity user 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

Logged-in User A ↓ Create POST ↓ GetUserId(User) ↓ OwnerId = User A ID ↓ Product saved

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:

Edit requested ↓ Product exists? ↓ Get current user ID ↓ product.OwnerId == userId? ┌──────────┴──────────┐ Yes No ↓ ↓ Edit form Forbid()

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));
}
Why server-side checking matters

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>
UI versus security

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.

Why keep them?

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
Checkpoint

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()
User B ↓ Edit User A Product ↓ Authenticated ✓ Owner? ✗ ↓ Forbidden

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

AspNetUsers Id ↑ │ Product.OwnerId ↑ │ Create POST ↑ GetUserId(User) ↑ Authenticated user

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 update
New Products have NULL OwnerId

Open the Create POST action and confirm the Product mapping contains:

OwnerId = userId
User can still access somebody else's Edit page

Check both Edit GET and Edit POST. Both must compare:

product.OwnerId != userId

and 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

  1. Create two user accounts.
  2. Log in as User A and create two Products.
  3. Open My Products and confirm both appear.
  4. Log out and log in as User B.
  5. Create one Product as User B.
  6. Confirm My Products shows only User B's Product.
  7. Confirm All Products still shows the public catalogue.
  8. Try to edit one of User A's Products using its URL.
  9. Confirm access is forbidden.
  10. Verify all OwnerId values directly in SQLite.

31. Knowledge Check

  1. Why is [Authorize] alone insufficient for user-owned records?
  2. What does OwnerId store?
  3. Why is OwnerId not included in ProductFormViewModel?
  4. How is the current user ID obtained?
  5. Why is OwnerId assigned during the Create POST action?
  6. What does Forbid() mean in the ownership checks?
  7. Why must Edit GET and Edit POST both check ownership?
  8. Why must Delete POST independently check ownership?
  9. What does the mine=true filter do?
  10. Why are Edit/Delete links hidden for non-owners even though controller checks already exist?
Show suggested answers
  1. Because it only proves the user is logged in, not that the user owns the requested Product.
  2. The ASP.NET Core Identity user ID of the Product owner.
  3. Because ownership is server-controlled and must not be chosen by the client.
  4. With _userManager.GetUserId(User).
  5. So ownership is established from the authenticated user at the moment the record is created.
  6. The user is authenticated but is not permitted to access that resource.
  7. Both displaying the edit form and applying the update are protected operations.
  8. Because a malicious user can submit a POST directly without using the visible UI.
  9. It restricts the Index query to Products whose OwnerId matches the current user.
  10. To provide a clear user interface; security still comes from server-side authorization.

32. Part 12 Summary

  • added OwnerId to Product;
  • created and applied the AddProductOwner migration;
  • 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

Purpose

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
Final Part 12 checkpoint

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.

Next: Part 13 — Roles and Administrator Authorization

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.