ASP.NET CORE MVC - Error Handling, Logging and Security Hardening

ASP.NET CORE MVC TUTORIAL SERIES · PART 16

Error Handling, Logging and Security Hardening

Add structured application logging, improve 404/status-code handling, review production exception handling, log denied authorization attempts, and audit the security controls already built into the Product Management application.

Objective

By the end of this tutorial, the application will produce useful structured logs, display friendly status-code pages, keep production errors generic, and apply a clear security checklist covering anti-forgery protection, ViewModels, authentication, ownership and administrator authorization.

Starting point

Part 15 added aggregate reporting. Part 16 does not change the database schema. It strengthens the application around failures, diagnostics and security.

In this tutorial
  1. Understand error handling versus logging
  2. Review development and production errors
  3. Add status-code handling in Program.cs
  4. Update HomeController.cs
  5. Create Status.cshtml
  6. Inject ILogger into ProductsController
  7. Log Create, Edit and Delete operations
  8. Log denied authorization attempts
  9. Review anti-forgery protection
  10. Review overposting protection
  11. Review authentication and authorization
  12. Test the hardened application
  13. Compare full final files in the appendix

1. Open and Verify the Part 15 Project

cd ~/aspnet-mvc-tutorial/ProductManagement
pwd
dotnet build
code .

The expected path is:

/home/xubuntu/aspnet-mvc-tutorial/ProductManagement
Checkpoint

Continue only when Part 15 builds successfully.

2. Error Handling and Logging Are Different

ConcernPurpose
Error handlingControl what the user receives when something goes wrong.
LoggingRecord diagnostic and operational information for developers and administrators.
SecurityPrevent unauthorized or unsafe application behaviour.
Problem occurs ↓ Application handles response ↓ User receives safe message At the same time ↓ ILogger records diagnostic information

3. Review Production Exception Handling in Program.cs

Open:

code Program.cs

Find the existing Part 13/15 production block:

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}

Keep it. In non-development environments, unhandled exceptions are routed through the generic /Home/Error endpoint instead of exposing exception details directly to users.

Security rule

Do not display stack traces, database connection information, secrets, source paths or raw exception messages on public production error pages.

4. Add Friendly Status-Code Handling to Program.cs

Still in Program.cs, find the closing brace of the production error block shown above.

Immediately after it and before app.UseHttpsRedirection();, add:

app.UseStatusCodePagesWithReExecute(
    "/Home/Status",
    "?code={0}");

The relevant section becomes:

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}

app.UseStatusCodePagesWithReExecute(
    "/Home/Status",
    "?code={0}");

app.UseHttpsRedirection();

This allows responses such as 404 to use a friendly MVC page while preserving the original HTTP status code.

5. Update HomeController.cs

Open:

code Controllers/HomeController.cs

Replace the entire file with:

using System.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using ProductManagement.Models;

namespace ProductManagement.Controllers;

public class HomeController : Controller
{
    private readonly ILogger<HomeController> _logger;

    public HomeController(
        ILogger<HomeController> logger)
    {
        _logger = logger;
    }

    public IActionResult Index()
    {
        return View();
    }

    public IActionResult Privacy()
    {
        return View();
    }

    [ResponseCache(
        Duration = 0,
        Location = ResponseCacheLocation.None,
        NoStore = true)]
    public IActionResult Error()
    {
        var exceptionFeature =
            HttpContext.Features
                .Get<IExceptionHandlerPathFeature>();

        if (exceptionFeature?.Error != null)
        {
            _logger.LogError(
                exceptionFeature.Error,
                "Unhandled exception on path {Path}.",
                exceptionFeature.Path);
        }

        return View(
            new ErrorViewModel
            {
                RequestId =
                    Activity.Current?.Id
                    ?? HttpContext.TraceIdentifier
            });
    }

    [ResponseCache(
        Duration = 0,
        Location = ResponseCacheLocation.None,
        NoStore = true)]
    public IActionResult Status(int code)
    {
        var statusFeature =
            HttpContext.Features
                .Get<IStatusCodeReExecuteFeature>();

        var originalPath =
            statusFeature?.OriginalPath
            ?? HttpContext.Request.Path;

        _logger.LogWarning(
            "HTTP status code {StatusCode} returned for {Path}.",
            code,
            originalPath);

        Response.StatusCode = code;

        return View(code);
    }
}

6. Understand ILogger<HomeController>

The controller constructor receives:

ILogger<HomeController> logger

ASP.NET Core provides logging through dependency injection. The logger category is associated with HomeController.

The application can then write structured logs such as:

_logger.LogWarning(
    "HTTP status code {StatusCode} returned for {Path}.",
    code,
    originalPath);
Structured logging

Use named placeholders such as {StatusCode} and {Path} rather than manually building one long string. Logging providers can retain those values as structured fields.

7. Understand the Error() Action

Inside HomeController.cs, find:

var exceptionFeature =
    HttpContext.Features
        .Get<IExceptionHandlerPathFeature>();

When production exception handling re-executes /Home/Error, this feature can provide information about the original exception and path.

The controller logs the exception:

_logger.LogError(
    exceptionFeature.Error,
    "Unhandled exception on path {Path}.",
    exceptionFeature.Path);

The user-facing Error view remains generic.

8. Create the Friendly Status View

Create:

touch Views/Home/Status.cshtml
code Views/Home/Status.cshtml

Add:

@model int

@{
    ViewData["Title"] = "Request Error";
}

<h1>Request Error</h1>

@if (Model == 404)
{
    <h2>404 - Page Not Found</h2>

    <p>
        The page or resource you requested could not be found.
    </p>
}
else if (Model == 403)
{
    <h2>403 - Access Denied</h2>

    <p>
        You do not have permission to access this resource.
    </p>
}
else
{
    <h2>Error @Model</h2>

    <p>
        The request could not be completed.
    </p>
}

<p>
    <a asp-controller="Home"
       asp-action="Index"
       class="btn btn-primary">
        Return Home
    </a>
</p>

9. How Status-Code Re-execution Works

Request unknown URL ↓ 404 response ↓ UseStatusCodePagesWithReExecute ↓ /Home/Status?code=404 ↓ HomeController.Status(404) ↓ Status.cshtml ↓ Friendly 404 page Status remains 404

10. Add Logging to ProductsController.cs

Open:

code Controllers/ProductsController.cs

Find these existing fields:

private readonly ApplicationDbContext _context;
private readonly UserManager<IdentityUser> _userManager;

Immediately below them, add:

private readonly ILogger<ProductsController> _logger;

11. Update the ProductsController Constructor

Find:

public ProductsController(
    ApplicationDbContext context,
    UserManager<IdentityUser> userManager)
{
    _context = context;
    _userManager = userManager;
}

Replace it with:

public ProductsController(
    ApplicationDbContext context,
    UserManager<IdentityUser> userManager,
    ILogger<ProductsController> logger)
{
    _context = context;
    _userManager = userManager;
    _logger = logger;
}

12. Log Successful Product Creation

Inside Create POST, find:

_context.Products.Add(product);
await _context.SaveChangesAsync();

return RedirectToAction(nameof(Index));

Insert the log after SaveChangesAsync():

_context.Products.Add(product);
await _context.SaveChangesAsync();

_logger.LogInformation(
    "Product {ProductId} created by user {UserId}.",
    product.Id,
    userId);

return RedirectToAction(nameof(Index));

13. Log Successful Product Updates

Inside Edit POST, find:

await _context.SaveChangesAsync();

return RedirectToAction(nameof(Index));

Immediately after saving, add:

_logger.LogInformation(
    "Product {ProductId} updated by user {UserId}.",
    product.Id,
    userId);

14. Log Successful Product Deletion

Inside DeleteConfirmed(), find:

_context.Products.Remove(product);
await _context.SaveChangesAsync();

return RedirectToAction(nameof(Index));

Replace that section with:

_context.Products.Remove(product);
await _context.SaveChangesAsync();

_logger.LogInformation(
    "Product {ProductId} deleted by user {UserId}.",
    id,
    userId);

return RedirectToAction(nameof(Index));

15. Log Denied Edit Attempts

Inside Edit GET, find:

if (product.OwnerId != userId
    && !User.IsInRole("Admin"))
{
    return Forbid();
}

Replace it with:

if (product.OwnerId != userId
    && !User.IsInRole("Admin"))
{
    _logger.LogWarning(
        "User {UserId} was denied Edit access to Product {ProductId}.",
        userId,
        product.Id);

    return Forbid();
}

Apply the same principle to Edit POST and Delete GET/POST. The full controller is provided in the appendix.

16. Why Log Authorization Failures?

A forbidden request may be an innocent mistake, but repeated denied requests can also help identify unexpected behaviour or attempted misuse.

Protected resource requested ↓ Owner/Admin check fails ↓ LogWarning(...) ↓ Forbid()
Do not over-log sensitive information

Never log passwords, authentication cookies, security tokens, connection-string secrets or other credentials. Logs themselves must also be protected in production.

17. Review Anti-Forgery Protection

Open:

code Controllers/ProductsController.cs

Verify every state-changing Product POST action contains:

[ValidateAntiForgeryToken]

This includes Create POST, Edit POST and Delete POST.

The MVC Form Tag Helper generates the corresponding anti-forgery token for normal forms.

What it protects against

Anti-forgery validation helps defend authenticated users against cross-site request forgery. It does not replace authentication, authorization, validation or ownership checks.

18. Review Overposting Protection

Open:

code ViewModels/ProductFormViewModel.cs

Verify that the form ViewModel contains only editable form data and does not contain:

OwnerId

Then open:

code Controllers/ProductsController.cs

Verify Create assigns ownership from the server:

OwnerId = userId

and Edit loads the real Product before copying allowed properties from the ViewModel.

Browser POST ↓ ProductFormViewModel ↓ Controller selects allowed fields ↓ Product entity ↓ SaveChangesAsync()

Microsoft's model-binding guidance recommends ViewModels as a protection against overposting, particularly for edit scenarios.

19. Review Authentication and Authorization

Verify the controller still uses:

[Authorize]

for Create/Edit/Delete, and ownership/Admin checks such as:

if (product.OwnerId != userId
    && !User.IsInRole("Admin"))
{
    return Forbid();
}

The layers now work together:

Authentication ↓ Is user signed in? ↓ Authorization ↓ Owner or Admin? ↓ Model validation ↓ Anti-forgery validation ↓ Database update

20. Review HTTPS and HSTS

Open Program.cs and keep:

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();

HTTPS protects data in transit. HSTS instructs compatible browsers to prefer HTTPS for the configured site after receiving the policy.

21. Build the Project

Save:

Program.cs
Controllers/HomeController.cs
Controllers/ProductsController.cs
Views/Home/Status.cshtml

Then run:

dotnet build
Checkpoint

Continue only when the project builds successfully.

22. Test the 404 Page

Run:

dotnet run

Open a URL that does not exist, for example:

/this-page-does-not-exist

You should see the friendly 404 page rather than an empty response.

Watch the Terminal. A warning log should be written for the 404 status.

23. Test Product Operation Logs

While logged in:

  1. Create a Product.
  2. Edit the Product.
  3. Delete the Product.
  4. Observe the Terminal after each action.

You should see structured Information-level messages containing Product ID and the acting user ID.

24. Test a Denied Authorization Attempt

Log in as a normal user and manually request another user's Edit URL:

/Products/Edit/ID

The server should deny access. The Terminal should also contain a Warning-level log recording the denied Product access.

25. Logging Levels

LevelTypical use
Trace / DebugDetailed development diagnostics.
InformationNormal important application events.
WarningUnexpected or suspicious conditions that do not stop the application.
ErrorAn operation failed because of an exception or serious problem.
CriticalSevere failures threatening application availability.

26. Logging Configuration in appsettings.json

Open:

code appsettings.json

Your existing logging section resembles:

"Logging": {
  "LogLevel": {
    "Default": "Information",
    "Microsoft.AspNetCore": "Warning"
  }
}

This controls the minimum log levels for categories. No change is required for this tutorial.

27. Security Audit Checklist

ControlCurrent implementation
Input validationData annotations and ModelState.
Overposting protectionProductFormViewModel plus explicit mapping.
CSRF protection[ValidateAntiForgeryToken] on state-changing MVC POST actions.
AuthenticationASP.NET Core Identity.
Ownership authorizationOwnerId compared with current Identity user ID.
Role authorizationAdmin role and User.IsInRole("Admin").
Admin-only endpoint[Authorize(Roles = "Admin")].
Transport securityHTTPS redirection and production HSTS.
Error disclosureGeneric production error page.
DiagnosticsStructured ILogger messages.

28. No Migration Is Required

Part 16 modifies middleware, controllers, logging and views only.

Do not create an EF Core migration

The database schema has not changed.

29. Troubleshooting

IStatusCodeReExecuteFeature cannot be found

Open HomeController.cs and confirm:

using Microsoft.AspNetCore.Diagnostics;
ILogger cannot be found

ILogger<T> is supplied by the standard ASP.NET Core logging infrastructure. Confirm the project still targets the ASP.NET Core Web SDK and rebuild with dotnet build.

The custom 404 page does not appear

Open Program.cs and verify UseStatusCodePagesWithReExecute appears before UseRouting(), and verify Views/Home/Status.cshtml exists.

Production Error() does not show exception text

This is intentional. Exception details are logged rather than displayed to the public user.

No Product logs appear

Confirm the controller receives ILogger<ProductsController> and that appsettings.json allows Information-level logs for the application category.

30. Hands-On Exercise

  1. Visit a nonexistent URL and observe the friendly 404 page.
  2. Create a Product and locate the Information log.
  3. Edit it and locate the update log.
  4. Delete it and locate the delete log.
  5. Attempt an unauthorized edit with another normal user and locate the Warning log.
  6. Verify each Product POST action still has [ValidateAntiForgeryToken].
  7. Verify OwnerId is absent from ProductFormViewModel.

31. Knowledge Check

  1. What is the difference between error handling and logging?
  2. Why should production error pages be generic?
  3. What does UseStatusCodePagesWithReExecute() do?
  4. What is ILogger<T> used for?
  5. Why use structured logging placeholders?
  6. Why log denied authorization attempts?
  7. What does anti-forgery validation protect against?
  8. Why does a ViewModel help prevent overposting?
  9. Why must ownership checks remain on the server?
  10. Does Part 16 require a migration?

32. Part 16 Summary

  • reviewed production exception handling;
  • added friendly status-code handling;
  • updated HomeController to log errors and HTTP status codes;
  • created a custom status page;
  • injected ILogger<ProductsController>;
  • logged Product Create/Edit/Delete operations;
  • logged denied authorization attempts;
  • reviewed anti-forgery protection;
  • reviewed ViewModel overposting protection;
  • reviewed Identity, ownership and role authorization;
  • reviewed HTTPS/HSTS; and
  • completed an application security checklist.

Appendix — Full Code for Final Verification

Purpose

Use this appendix after completing Part 16. Compare every file modified in this tutorial with the complete final version below.

Appendix A — Program.cs

using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Data;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();

builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlite(
        builder.Configuration.GetConnectionString(
            "DefaultConnection")));

builder.Services
    .AddDefaultIdentity<IdentityUser>(options =>
    {
        options.SignIn.RequireConfirmedAccount = false;
    })
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    await IdentitySeedData.InitializeAsync(
        scope.ServiceProvider,
        app.Configuration);
}

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}

app.UseStatusCodePagesWithReExecute(
    "/Home/Status",
    "?code={0}");

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

app.MapRazorPages();

app.Run();

Appendix B — Controllers/HomeController.cs

using System.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using ProductManagement.Models;

namespace ProductManagement.Controllers;

public class HomeController : Controller
{
    private readonly ILogger<HomeController> _logger;

    public HomeController(
        ILogger<HomeController> logger)
    {
        _logger = logger;
    }

    public IActionResult Index()
    {
        return View();
    }

    public IActionResult Privacy()
    {
        return View();
    }

    [ResponseCache(
        Duration = 0,
        Location = ResponseCacheLocation.None,
        NoStore = true)]
    public IActionResult Error()
    {
        var exceptionFeature =
            HttpContext.Features
                .Get<IExceptionHandlerPathFeature>();

        if (exceptionFeature?.Error != null)
        {
            _logger.LogError(
                exceptionFeature.Error,
                "Unhandled exception on path {Path}.",
                exceptionFeature.Path);
        }

        return View(
            new ErrorViewModel
            {
                RequestId =
                    Activity.Current?.Id
                    ?? HttpContext.TraceIdentifier
            });
    }

    [ResponseCache(
        Duration = 0,
        Location = ResponseCacheLocation.None,
        NoStore = true)]
    public IActionResult Status(int code)
    {
        var statusFeature =
            HttpContext.Features
                .Get<IStatusCodeReExecuteFeature>();

        var originalPath =
            statusFeature?.OriginalPath
            ?? HttpContext.Request.Path;

        _logger.LogWarning(
            "HTTP status code {StatusCode} returned for {Path}.",
            code,
            originalPath);

        Response.StatusCode = code;

        return View(code);
    }
}

Appendix C — Views/Home/Status.cshtml

@model int

@{
    ViewData["Title"] = "Request Error";
}

<h1>Request Error</h1>

@if (Model == 404)
{
    <h2>404 - Page Not Found</h2>

    <p>
        The page or resource you requested could not be found.
    </p>
}
else if (Model == 403)
{
    <h2>403 - Access Denied</h2>

    <p>
        You do not have permission to access this resource.
    </p>
}
else
{
    <h2>Error @Model</h2>

    <p>
        The request could not be completed.
    </p>
}

<p>
    <a asp-controller="Home"
       asp-action="Index"
       class="btn btn-primary">
        Return Home
    </a>
</p>

Appendix D — 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;
    private readonly ILogger<ProductsController> _logger;

    public ProductsController(
        ApplicationDbContext context,
        UserManager<IdentityUser> userManager,
        ILogger<ProductsController> logger)
    {
        _context = context;
        _userManager = userManager;
        _logger = logger;
    }

    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();

        _logger.LogInformation(
            "Product {ProductId} created by user {UserId}.",
            product.Id,
            userId);

        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
            && !User.IsInRole("Admin"))
        {
            _logger.LogWarning(
                "User {UserId} was denied Edit access to Product {ProductId}.",
                userId,
                product.Id);

            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
            && !User.IsInRole("Admin"))
        {
            _logger.LogWarning(
                "User {UserId} was denied update access to Product {ProductId}.",
                userId,
                product.Id);

            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();

        _logger.LogInformation(
            "Product {ProductId} updated by user {UserId}.",
            product.Id,
            userId);

        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
            && !User.IsInRole("Admin"))
        {
            _logger.LogWarning(
                "User {UserId} was denied Delete access to Product {ProductId}.",
                userId,
                product.Id);

            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
            && !User.IsInRole("Admin"))
        {
            _logger.LogWarning(
                "User {UserId} was denied delete submission for Product {ProductId}.",
                userId,
                product.Id);

            return Forbid();
        }

        _context.Products.Remove(product);
        await _context.SaveChangesAsync();

        _logger.LogInformation(
            "Product {ProductId} deleted by user {UserId}.",
            id,
            userId);

        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();
    }
}

Appendix E — Final Verification Commands

cd ~/aspnet-mvc-tutorial/ProductManagement

dotnet build
dotnet run

Test:

/Products
/this-page-does-not-exist
/Products/Create
/Products/Edit/1
/Products/Delete/1
Final Part 16 checkpoint

If the project builds, nonexistent pages show a friendly 404 response, Product operations produce useful logs, denied ownership requests are logged and forbidden, and the existing security controls remain intact, Part 16 is complete.

Next: Part 17 — REST API Introduction

Part 17 will expose Product data through JSON using an [ApiController], HTTP GET/POST/PUT/DELETE endpoints and appropriate HTTP status codes while keeping the existing MVC interface.