Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close

    Module 8 · 8. Testing, Web APIs, Security, and Production Engineering · Lesson 24 of 24

    Security, Configuration, Health Checks, and Production Delivery

    What you will build and what it proves

    Production engineering starts by making a claim precise enough to challenge. “The invoice endpoint is secure” hides several claims: who supplied the identity, which invoice was selected, what permission was required, and what happened when one condition failed. In this lesson you will run two small console programs that isolate those decisions, then solve a deployment exercise where a superficially successful release can still damage data.

    The first program calls the real ASP.NET Core authorization service against an invoice fixture. The second binds in-memory settings, rejects invalid startup configuration, and evaluates two groups of framework health checks. Both use .NET 10 and the shared ASP.NET Core framework; neither needs an additional package. Keep the two programs in separate folders. Each project has its own Program.cs and the project file shown below. Run each with dotnet run --project followed by its project path.

    These are framework-component exercises. The identity is synthetic, the invoice is a server-owned fixture, and dependency availability is explicitly simulated. No web server, real identity provider, database, external service, secret, charge, or refund is involved. The earlier refund-shaped fragment did not include a complete authentication setup or refund implementation; this safe read example replaces it so the behavior we inspect is complete and bounded.

    Authorize the actual invoice

    Resource-based authorization supplies the user and loaded resource to IAuthorizationService. A typed handler evaluates a requirement against that resource; the returned AuthorizationResult reports whether authorization succeeded. A policy can combine multiple requirements. Microsoft resource authorization guidance.

    Our rule is deliberately narrow: an authenticated principal may read an invoice only with invoices.read permission, one matching tenant claim, and one matching owner identifier. An invoice identifier tells the server which record to look up; it does not establish permission. In this exercise the immutable Invoice object is created by the program, rather than assembled from an alleged tenant in a request. The rule is an owner-only product decision, not a universal rule for every invoice system.

    learner/Authorization/Authorization.csproj

    XML
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
      </PropertyGroup>
      <ItemGroup>
        <FrameworkReference Include="Microsoft.AspNetCore.App" />
      </ItemGroup>
    </Project>

    learner/Authorization/Program.cs

    C#
    using System.Security.Claims;
    using Microsoft.AspNetCore.Authorization;
    using Microsoft.Extensions.DependencyInjection;
    
    namespace InvoiceAuthorization;
    
    public static class Program
    {
        public static async Task Main()
        {
            using ServiceProvider services = AccessRules.BuildServices();
            var authorization = services.GetRequiredService<IAuthorizationService>();
            // Fixture owned by this program, standing in for a server-side lookup.
            var invoice = new Invoice("inv-17", "tenant-blue", "user-7");
            var cases = new (string Name, ClaimsPrincipal User)[]
            {
                ("owner with permission", DemoUsers.Make("user-7", "tenant-blue", true)),
                ("missing permission", DemoUsers.Make("user-7", "tenant-blue", false)),
                ("different tenant", DemoUsers.Make("user-7", "tenant-red", true)),
                ("different owner", DemoUsers.Make("user-8", "tenant-blue", true)),
                ("anonymous", DemoUsers.Make("user-7", "tenant-blue", true, false))
            };
    
            foreach (var item in cases)
            {
                AuthorizationResult result = await authorization.AuthorizeAsync(
                    item.User, invoice, AccessRules.PolicyName);
                Console.WriteLine($"{item.Name}: {(result.Succeeded ? "ALLOW" : "DENY")}");
            }
        }
    }
    
    public sealed record Invoice(string Id, string TenantId, string OwnerId);
    public sealed class InvoiceOwnerRequirement : IAuthorizationRequirement { }
    
    public sealed class InvoiceOwnerHandler
        : AuthorizationHandler<InvoiceOwnerRequirement, Invoice>
    {
        protected override Task HandleRequirementAsync(
            AuthorizationHandlerContext context,
            InvoiceOwnerRequirement requirement,
            Invoice resource)
        {
            string? tenant = SingleClaim(context.User, "tenant_id");
            string? owner = SingleClaim(context.User, ClaimTypes.NameIdentifier);
            if (tenant is not null && owner is not null &&
                string.Equals(tenant, resource.TenantId, StringComparison.Ordinal) &&
                string.Equals(owner, resource.OwnerId, StringComparison.Ordinal))
            {
                context.Succeed(requirement);
            }
            return Task.CompletedTask;
        }
    
        private static string? SingleClaim(ClaimsPrincipal user, string type)
        {
            Claim[] claims = user.FindAll(type).ToArray();
            return claims.Length == 1 && !string.IsNullOrWhiteSpace(claims[0].Value)
                ? claims[0].Value : null;
        }
    }
    
    public static class AccessRules
    {
        public const string PolicyName = "ReadOwnInvoice";
    
        public static ServiceProvider BuildServices()
        {
            var services = new ServiceCollection();
            services.AddLogging();
            services.AddAuthorizationCore(options =>
                options.AddPolicy(PolicyName, policy => policy
                    .RequireAuthenticatedUser()
                    .RequireClaim("permission", "invoices.read")
                    .AddRequirements(new InvoiceOwnerRequirement())));
            services.AddSingleton<IAuthorizationHandler, InvoiceOwnerHandler>();
            return services.BuildServiceProvider();
        }
    }
    
    public static class DemoUsers
    {
        // Synthetic fixtures only: this method does not authenticate anyone.
        public static ClaimsPrincipal Make(string owner, string tenant,
            bool permission, bool authenticated = true)
        {
            var claims = new List<Claim>
            {
                new(ClaimTypes.NameIdentifier, owner),
                new("tenant_id", tenant)
            };
            if (permission)
                claims.Add(new Claim("permission", "invoices.read"));
            return new ClaimsPrincipal(new ClaimsIdentity(
                claims, authenticated ? "synthetic-demo" : null));
        }
    }

    Expected output

    Code
    owner with permission: ALLOW
    missing permission: DENY
    different tenant: DENY
    different owner: DENY
    anonymous: DENY

    Trace the first case across the boundary. The policy asks for an authenticated user and the read permission. InvoiceOwnerHandler then compares tenant-blue with the resource’s tenant-blue and user-7 with its user-7. Only that custom requirement is marked successful by the handler; the final policy result also depends on its other requirements. Removing the permission therefore produces DENY even though the handler still recognizes the owner. This is why testing the handler alone would leave an important part of this example unexamined.

    Now follow the cross-tenant case. The subject remains user-7 and the permission remains present, but tenant-red does not equal tenant-blue. Matching a subject alone is insufficient for this rule. The anonymous case has convincing-looking claim text yet still fails the authentication requirement. DemoUsers intentionally makes such a fixture; its authentication-type string is only a test switch. Constructing a principal with that string proves nothing about a person’s real identity.

    SingleClaim rejects both missing claims and duplicate claims instead of silently choosing the first. This is a defensive choice for this exercise’s single-identity contract. It does not implement a complete identity-normalization strategy for applications that intentionally combine multiple identities. The tenant and owner comparisons are ordinal; no culture-specific spelling conversion is part of the rule. Keep those decisions explicit when adapting the exercise to a real product.

    Authorization exercise with a worked answer

    Try first. Invoice inv-17 still belongs to tenant-blue and user-7. Predict the result for (1) user-7 in tenant-blue with only invoices.write, (2) user-7 in tenant-red plus a request body claiming tenant-blue, and (3) user-7 in tenant-blue with two identical tenant claims. Name the exact condition that rejects each request. Then propose the smallest extra test that would catch an implementation which accidentally stopped checking ownership.

    Worked answer. All three are denied. Case 1 lacks the policy’s read permission. Case 2 fails the comparison with the server-owned resource; no request-body field participates in this program’s decision. Case 3 fails SingleClaim because two claims are ambiguous under this contract even when their text matches. For the ownership regression, keep the tenant and read permission correct but use user-8 against user-7’s invoice. The harness in the companion PDF appendix includes these kinds of isolated counterexamples and compiles this exact learner source rather than reproducing its rule.

    A passing authorization result here is not a JWT test. Signature, issuer, audience, token lifetime, claim mapping, HTTP middleware ordering, challenge/forbid responses, and the real data lookup still need separate coverage in an application that uses them. In particular, do not replace validated identity with a principal assembled from arbitrary request fields. This lesson’s fixture factory belongs in the exercise, not in a production login flow.

    Validate settings and evaluate health groups

    The options pattern groups related settings in a typed class. Binding populates that class; registered validation rules can reject invalid values. ValidateOnStart requests validation during host startup instead of waiting for the first options access. Microsoft options guidance.

    HealthCheckService evaluates registered checks, and tags let callers select a group. Separate liveness and readiness signals distinguish process-level failure from inability to accept traffic. A deployment controller interprets those signals; evaluating a health check does not itself restart the process. Microsoft health-check guidance.

    This exercise has two settings: a nonblank Region and Workers from one through eight. Zero is intentionally rejected, so a missing numeric setting cannot accidentally create an apparently valid worker configuration. The program first attempts invalid startup, then starts a valid in-process Generic Host. It never starts an HTTP listener or an operating-system service. Its only configuration source is the dictionary supplied by LessonHost.Build.

    learner/OptionsHealth/OptionsHealth.csproj

    XML
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
      </PropertyGroup>
      <ItemGroup>
        <FrameworkReference Include="Microsoft.AspNetCore.App" />
      </ItemGroup>
    </Project>

    learner/OptionsHealth/Program.cs

    C#
    using System.Globalization;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Diagnostics.HealthChecks;
    using Microsoft.Extensions.Hosting;
    using Microsoft.Extensions.Logging;
    using Microsoft.Extensions.Options;
    
    namespace ConfigurationAndHealth;
    
    public static class Program
    {
        public static async Task Main()
        {
            using (IHost invalid = LessonHost.Build("lab", 0))
            {
                try
                {
                    await invalid.StartAsync();
                    throw new InvalidOperationException("Invalid options were accepted.");
                }
                catch (OptionsValidationException)
                {
                    Console.WriteLine("invalid options: rejected at startup");
                }
            }
    
            using IHost host = LessonHost.Build("lab", 3);
            await host.StartAsync();
            try
            {
                var options = host.Services.GetRequiredService<IOptions<WorkerOptions>>().Value;
                Console.WriteLine($"options: region={options.Region} workers={options.Workers}");
                var state = host.Services.GetRequiredService<DependencySimulation>();
                var checks = host.Services.GetRequiredService<HealthCheckService>();
    
                await Print("live initially", "live");
                await Print("ready initially", "ready");
                state.Available = true;
                await Print("ready after recovery", "ready");
                state.Available = false;
                await Print("live during outage", "live");
                await Print("ready during outage", "ready");
                Console.WriteLine($"simulated dependency evaluations: {state.Evaluations}");
    
                async Task Print(string label, string tag)
                {
                    HealthReport report = await checks.CheckHealthAsync(
                        registration => registration.Tags.Contains(tag));
                    Console.WriteLine($"{label}: {report.Status}");
                }
            }
            finally
            {
                await host.StopAsync();
            }
        }
    }
    
    public sealed class WorkerOptions
    {
        public string Region { get; set; } = "";
        public int Workers { get; set; }
    }
    
    public sealed class DependencySimulation
    {
        // Explicit simulation: no database or network dependency is contacted.
        public bool Available { get; set; }
        public int Evaluations { get; set; }
    }
    
    public sealed class DependencyReadiness(DependencySimulation state) : IHealthCheck
    {
        public Task<HealthCheckResult> CheckHealthAsync(
            HealthCheckContext context, CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            state.Evaluations++;
            return Task.FromResult(state.Available
                ? HealthCheckResult.Healthy("Simulated dependency is available.")
                : HealthCheckResult.Unhealthy("Simulated dependency is unavailable."));
        }
    }
    
    public static class LessonHost
    {
        public static IHost Build(string region, int workers) => new HostBuilder()
            .ConfigureAppConfiguration((_, configuration) =>
                configuration.AddInMemoryCollection(new Dictionary<string, string?>
                {
                    ["Worker:Region"] = region,
                    ["Worker:Workers"] = workers.ToString(CultureInfo.InvariantCulture)
                }))
            .ConfigureLogging(logging => logging.ClearProviders())
            .ConfigureServices((context, services) =>
            {
                services.AddOptions<WorkerOptions>()
                    .Bind(context.Configuration.GetSection("Worker"))
                    .Validate(value => !string.IsNullOrWhiteSpace(value.Region),
                        "Region is required.")
                    .Validate(value => value.Workers is >= 1 and <= 8,
                        "Workers must be between 1 and 8.")
                    .ValidateOnStart();
                services.AddSingleton<DependencySimulation>();
                services.AddHealthChecks()
                    .AddCheck("process", () => HealthCheckResult.Healthy(),
                        tags: new[] { "live" })
                    .AddCheck<DependencyReadiness>("simulated-dependency",
                        tags: new[] { "ready" });
            })
            .Build();
    }

    Expected output

    Code
    invalid options: rejected at startup
    options: region=lab workers=3
    live initially: Healthy
    ready initially: Unhealthy
    ready after recovery: Healthy
    live during outage: Healthy
    ready during outage: Unhealthy
    simulated dependency evaluations: 3

    Read the output as a sequence of controlled observations. The first host cannot complete startup with zero workers. The second binds lab and 3, which are ordinary demonstration values, not secrets. Initially the simulated dependency is unavailable, so ready is Unhealthy while live is Healthy. Setting Available to true changes only the next readiness result. Setting it back to false models another outage. There are three dependency evaluations because only the three ready calls include that check.

    The live group’s constant healthy result proves only that this selected callback ran successfully inside the process. It does not prove the process can never deadlock, that the public endpoint is reachable, or that a platform has configured useful probe thresholds. Similarly, Available is a Boolean fixture, not evidence that a database accepts queries. The sample uses explicit serial steps so the result does not depend on a race, a timer, a sleep, or a lucky scheduling order.

    Configuration and health exercises with answers

    Try first. Predict startup for worker counts 0, 1, 8, and 9. What happens if Region contains spaces? Next, change the live predicate in your working copy to select every registration. How would an unavailable simulated dependency affect that report, and why would that be a poor match for the intended live group?

    Worked answer. Zero and nine fail; one and eight pass. A whitespace-only region fails its separate rule. Selecting every registration brings simulated-dependency into the live evaluation, so its Unhealthy result contaminates that group. If an operator subsequently used that result as a restart trigger, restarting would not repair the fixture’s unavailable dependency. The appropriate program repair is to restore the live tag filter. The operational decision belongs to the hosting platform and is outside this console exercise.

    The harness checks both valid boundaries, three invalid counts, the blank region, selected check names, recovery, a second outage, and the evaluation counter. It does not test a real dependency timeout or cancellation under load. Before adopting a network-backed readiness check, define a finite probe budget and demonstrate how it behaves when the dependency stalls. That is a separate experiment; adding a timeout-shaped parameter without exercising the failure would not establish the result.

    Configuration values are not a secret-storage design

    Keep real secrets out of source and client-delivered code. Development Secret Manager is not encrypted and is not a production secret store. Environment variables are also not inherently protected from a compromised process or machine; production secret access needs controlled storage and access. Microsoft secret-storage guidance.

    Consider a review of this program. One reviewer suggests printing every configuration key to debug an incorrect worker count. Another suggests printing only the validation message “Workers must be between 1 and 8.” Choose the second approach for this exercise: it identifies the broken rule without creating a habit of dumping unrelated values. Region and Workers are harmless fixtures here, but the debugging pattern may later be copied into a host with credentials. Nothing about successful options binding certifies that a setting is safe to display.

    Worked expand-and-contract rollout decision

    Safe deployment uses progressive exposure with health evaluation before wider rollout. Stop expansion when a release causes problems and initiate recovery. Recovery planning must account for stateful changes; reverting application code is not always enough to undo a release. Microsoft safe-deployment guidance.

    Scenario. Version 1 stores invoice delivery text in DeliveryAddress. Version 2 introduces PostalAddress. Old and new instances will overlap. Existing rows need copying, and users may edit addresses throughout the release. A product owner proposes adding the new column, copying everything once, switching the read, and dropping DeliveryAddress that evening. Decide what to change, what evidence allows each step, and what happens if version 2 starts returning errors halfway through the rollout.

    Worked plan, phase 1. Add PostalAddress as an optional column without removing or renaming DeliveryAddress. Version 1 continues using its old shape. Introduce a bridge version that still treats DeliveryAddress as authoritative but writes both columns together for every address change. During the overlap, version 1 may still update only the old column, so new reads must not yet assume that a populated PostalAddress is fresh. Wait until all old writers are gone, including background jobs, before treating the two columns as synchronized.

    Phase 2. Backfill in bounded batches with a concurrency guard: update PostalAddress only if the observed row version has not changed since that row was read. A skipped row is retried from fresh data; otherwise a stale copy could overwrite a newer address. Compare values and count unresolved mismatches after backfill. Demonstrate both old and new read paths against representative rows before changing the authoritative read. These are acceptance requirements for the real database experiment, not actions performed by this lesson.

    Phase 3. Expose the new read behavior to a small cohort while the bridge keeps both columns current. If errors appear halfway through, stop expansion and return reads to DeliveryAddress using the compatible bridge build or flag. Keep the additive schema. Compare error rate and address correctness after recovery. Do not immediately roll back to a much older writer that updates only the old column while new readers remain active; that would reintroduce divergence.

    Phase 4 and decision. Delay dropping DeliveryAddress until no supported reader, writer, job, report, or rollback build needs it, and the agreed observation period has passed. If the old column has already been dropped, a simple binary rollback to version 1 is no longer safe. Choose a compatible forward repair or a separately rehearsed data-recovery procedure. The lesson’s answer is therefore “expand first, maintain compatibility, prove convergence, switch gradually, contract later,” with a named rollback target at each phase.

    Same-database retries and the external-effect boundary

    A database transaction groups operations into one commit or rollback boundary. EF Core normally applies one SaveChanges call transactionally when the provider supports transactions. Verify provider behavior rather than assuming every storage system supplies that guarantee. Microsoft transaction guidance.

    For the assessment, use an order-creation operation whose effects all reside in one transactional database. Design an operation record keyed by caller and idempotency key, with a request fingerprint and replayable result. The operation, new order, and result belong in the same transaction, protected by database-enforced key uniqueness. A concurrent loser re-reads the winner’s committed record; it does not create another order. A repeated key with a different fingerprint is rejected rather than mistaken for the original request.

    Crash exercise. Suppose the transaction commits order 417 but the response disappears. The retry returns the recorded outcome for 417. If the transaction never committed, the retry can attempt the operation anew. Now change the story: an external charge succeeds and the process crashes before recording it locally. The local record alone cannot establish whether that external effect happened. That changed problem requires a payment-provider-specific idempotency and reconciliation design. This lesson neither implements nor tests payments, database transactions, or provider recovery; the exercise explains why the assessment must keep its same-database scope.

    Explain the evidence in an interview

    Describe the demonstrated boundary before naming the API: “I exercised authorization policy decisions with synthetic identities, startup validation with in-memory settings, and selected health checks with a simulated dependency.” Then name the untested boundary and the next experiment. For the module’s broader assessment, a pure calculation is a unit-test candidate, consumer expectations belong in contract coverage, and 400 validation ProblemDetails is the API lesson’s chosen response contract. Do not claim those behaviors were exercised by these two programs. A good production answer connects a precise rule, a counterexample, and the evidence still needed before release.

    Analogy

    Everyday picture

    At a small ferry terminal, the dispatcher answers a radio check, but a blocked landing prevents taking passengers. Two lamps answer separate questions: “Can the dispatcher answer this check?” and “Is the landing available for boarding?” Clearing the landing changes the second lamp without requiring a new dispatcher. Wiring both lamps to the landing would make a dock closure look like the dispatcher had disappeared.

    Mapping. live selects the constant process callback; ready selects DependencyReadiness. DependencySimulation.Available changes only the next readiness observation. The report is an observation for a hosting platform to interpret; HealthCheckService does not itself restart anything. Restarting the process would not repair this simulated dependency.

    Where it stops. The radio reply models only a successful callback, not proof that the whole process cannot deadlock. The landing is a Boolean fixture, not a tested database or network service. No HTTP probe, platform routing decision, or production-readiness guarantee was demonstrated.

    Cheat sheet (PDF)

    csharp-authorization-configuration-safe-delivery-companion.pdf17 pages · 97 KB
    Every page, in this page.

    Practice

    Sign in to mark lessons done and keep your place in the course.Sign in