Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Logging and Security

"There are two domains where 'I forgot to add it' stops being a code review comment and starts being a production incident: logging and security. One makes the incident invisible. The other makes the incident possible."


Two Domains, One Pattern

Logging and security are cross-cutting concerns. They touch every service, every endpoint, every handler. They are the two domains where Convention's double cost is not just overhead — it is multiplied across the entire surface area of the application.

A validation convention affects 120 command types. A logging convention affects every public method in every service. A security convention affects every endpoint in every controller. The Convention Tax is proportional to the surface area, and cross-cutting concerns have the largest surface area of all.

These two domains look different on the surface — one is observability, the other is access control — but the pattern underneath is identical:

  1. Code: Developers write it by hand, inconsistently, everywhere.
  2. Configuration: XML files separate the policy from the code. Two truth sources drift.
  3. Convention: Frameworks provide abstractions. Teams document rules. Teams write enforcement tests. Developers forget.
  4. Contention: Attributes declare intent. Source Generators produce the implementation. Analyzers refuse to compile violations.

This part covers both domains side by side to show that the pattern is not domain-specific. It is structural.


Era 1: Code — Console.WriteLine

In the beginning, logging was printing. Wherever the developer remembered to print.

// Era 1: Code — manual logging in the service
public class OrderService
{
    private readonly IOrderRepository _repository;

    public OrderService(IOrderRepository repository)
    {
        _repository = repository;
    }

    public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
    {
        Console.WriteLine($"Creating order for customer {request.CustomerId}");
        Console.WriteLine($"Items: {request.Items.Count}");
        Console.WriteLine($"Shipping to: {request.ShippingAddress.Street}, " +
            $"{request.ShippingAddress.City}, {request.ShippingAddress.PostalCode}");

        var order = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = request.CustomerId,
            Items = request.Items.Select(i => new LineItem
            {
                ProductId = i.ProductId,
                Quantity = i.Quantity,
                UnitPrice = i.UnitPrice
            }).ToList(),
            CreatedAt = DateTime.UtcNow
        };

        await _repository.AddAsync(order);

        Console.WriteLine($"Order {order.Id} created successfully for {request.CustomerId}");
        Console.WriteLine($"Customer email: {request.CustomerEmail}");  // PII leak
        Console.WriteLine($"Total: {order.Total}, Card: {request.PaymentCard}");  // PCI violation

        return order;
    }

    public async Task CancelOrderAsync(Guid orderId)
    {
        // Developer B doesn't log at all.
        // When this fails in production, there is no trace.
        var order = await _repository.GetByIdAsync(orderId);
        order.Cancel();
        await _repository.UpdateAsync(order);
    }

    public async Task<Order?> GetOrderAsync(Guid orderId)
    {
        // Developer C logs differently from Developer A.
        System.Diagnostics.Debug.WriteLine($"Getting order {orderId}");
        var order = await _repository.GetByIdAsync(orderId);
        if (order == null)
            System.Diagnostics.Debug.WriteLine("NOT FOUND!!!");
        return order;
    }
}

What goes wrong:

  • Console.WriteLine has no log levels. No structured data. No correlation IDs. No filtering.
  • PII (CustomerEmail) and PCI data (PaymentCard) written to stdout. In a containerized environment, that goes to the log aggregator. In the log aggregator, it is retained for months. Compliance violation.
  • Developer B logs nothing. When CancelOrderAsync throws, the incident responder sees the exception but has no context — no order ID, no customer, no state.
  • Developer C uses Debug.WriteLine, which is stripped in Release builds. Zero production visibility.
  • Three developers, three styles, one service. Multiply by 50 services.

Era 2: Configuration — NLog XML

NLog and log4net separated the where (targets/appenders) from the what (log calls). The routing rules lived in XML. The format lived in XML. The log calls lived in C#.

<!-- nlog.config — the logging configuration file -->
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">

  <targets>
    <target name="console"
            xsi:type="Console"
            layout="${longdate} | ${level:uppercase=true:padding=5} | ${logger} | ${message} ${exception:format=tostring}" />

    <target name="file"
            xsi:type="File"
            fileName="${basedir}/logs/${shortdate}.log"
            layout="${longdate} | ${level:uppercase=true:padding=5} | ${logger} | ${message} ${exception:format=tostring}"
            archiveEvery="Day"
            maxArchiveFiles="30" />

    <target name="jsonFile"
            xsi:type="File"
            fileName="${basedir}/logs/${shortdate}.json">
      <layout xsi:type="JsonLayout">
        <attribute name="time" layout="${longdate}" />
        <attribute name="level" layout="${level}" />
        <attribute name="logger" layout="${logger}" />
        <attribute name="message" layout="${message}" />
        <attribute name="exception" layout="${exception:format=tostring}" />
      </layout>
    </target>
  </targets>

  <rules>
    <logger name="*" minlevel="Info" writeTo="console" />
    <logger name="*" minlevel="Debug" writeTo="file" />
    <logger name="MyApp.Services.*" minlevel="Trace" writeTo="jsonFile" />
    <logger name="Microsoft.*" maxlevel="Warning" final="true" />
  </rules>

</nlog>
// C# code using NLog
public class OrderService
{
    private static readonly Logger _logger = LogManager.GetCurrentClassLogger();

    public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
    {
        _logger.Info("Creating order for customer {0}", request.CustomerId);

        var order = new Order { /* ... */ };
        await _repository.AddAsync(order);

        _logger.Info("Order {0} created, total: {1:C}", order.Id, order.Total);
        return order;
    }
}

What goes wrong:

  • The format is defined in XML. The log calls are in C#. Two truth sources.
  • Change the JSON layout to add a correlationId attribute? You must also update every log call to include the correlation ID. The XML says "put it in the output." The C# says "I never included it in the message."
  • The XML configures routing (where logs go). It does not enforce content (what developers log). The same inconsistency from Era 1 persists — Developer B still logs nothing, Developer C still uses Debug.WriteLine.
  • NLog configuration is parsed at startup. Typo in the XML? Runtime failure. No compile-time validation.

Era 3: Convention — ILogger and Serilog

ASP.NET Core introduced ILogger<T> as the standard abstraction. Serilog brought structured logging — named placeholders instead of positional {0}. LoggerMessage.Define brought high-performance static log methods.

// Era 3: Convention — structured logging with ILogger<T>
public class OrderService
{
    private readonly ILogger<OrderService> _logger;
    private readonly IOrderRepository _repository;

    public OrderService(ILogger<OrderService> logger, IOrderRepository repository)
    {
        _logger = logger;
        _repository = repository;
    }

    public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
    {
        _logger.LogInformation("Creating order for customer {CustomerId} with {ItemCount} items",
            request.CustomerId, request.Items.Count);

        var order = new Order { /* ... */ };

        try
        {
            await _repository.AddAsync(order);
            _logger.LogInformation("Order {OrderId} created successfully, total: {OrderTotal}",
                order.Id, order.Total);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to create order for customer {CustomerId}", request.CustomerId);
            throw;
        }

        return order;
    }
}
// High-performance logging with LoggerMessage.Define (manual)
public static partial class OrderServiceLogMessages
{
    private static readonly Action<ILogger, string, int, Exception?> _creatingOrder =
        LoggerMessage.Define<string, int>(
            LogLevel.Information,
            new EventId(1001, nameof(CreatingOrder)),
            "Creating order for customer {CustomerId} with {ItemCount} items");

    public static void CreatingOrder(ILogger logger, string customerId, int itemCount)
        => _creatingOrder(logger, customerId, itemCount, null);

    private static readonly Action<ILogger, Guid, decimal, Exception?> _orderCreated =
        LoggerMessage.Define<Guid, decimal>(
            LogLevel.Information,
            new EventId(1002, nameof(OrderCreated)),
            "Order {OrderId} created successfully, total: {OrderTotal}");

    public static void OrderCreated(ILogger logger, Guid orderId, decimal orderTotal)
        => _orderCreated(logger, orderId, orderTotal, null);

    private static readonly Action<ILogger, string, Exception?> _orderCreationFailed =
        LoggerMessage.Define<string>(
            LogLevel.Error,
            new EventId(1003, nameof(OrderCreationFailed)),
            "Failed to create order for customer {CustomerId}");

    public static void OrderCreationFailed(ILogger logger, string customerId, Exception ex)
        => _orderCreationFailed(logger, customerId, ex);
}

Better. Structured logging. Named placeholders. Typed parameters. The LoggerMessage.Define approach is high-performance because it avoids the allocation of params object[] on every log call.

But now the team needs conventions.

The Documentation

<!-- wiki/logging-standards.md — the document every team writes -->

# Logging Standards

## Required Practices
- All service classes MUST inject ILogger<T>
- All public methods MUST log at entry (Information) and exit (Information)
- All catch blocks MUST log at Error level with the exception
- Use structured logging: named placeholders, not string interpolation
- NEVER log PII: email, phone, SSN, payment card, IP address
- NEVER use Console.WriteLine or Debug.WriteLine
- NEVER use string interpolation in log calls ($"message {value}")
  - Bad:  _logger.LogInformation($"Order {orderId} created")
  - Good: _logger.LogInformation("Order {OrderId} created", orderId)

## Event IDs
- 1xxx: Order domain
- 2xxx: Customer domain
- 3xxx: Inventory domain
- 4xxx: Payment domain (CAUTION: never log amounts > $10,000 or card numbers)

## Performance
- Use LoggerMessage.Define for hot paths
- Use [LoggerMessage] source generator where available
- Avoid logging inside loops

The Enforcement Code

// Tests to enforce logging conventions
[Fact]
public void All_Services_Should_Inject_ILogger()
{
    var serviceTypes = typeof(OrderService).Assembly
        .GetTypes()
        .Where(t => t.Name.EndsWith("Service") && !t.IsAbstract);

    foreach (var service in serviceTypes)
    {
        var constructors = service.GetConstructors();
        var hasLogger = constructors.Any(c =>
            c.GetParameters().Any(p =>
                p.ParameterType.IsGenericType &&
                p.ParameterType.GetGenericTypeDefinition() == typeof(ILogger<>)));

        hasLogger.Should().BeTrue(
            $"Service {service.Name} must inject ILogger<{service.Name}>");
    }
}

[Fact]
public void No_Service_Should_Use_Console_WriteLine()
{
    // This test scans source files with string matching.
    // Yes, teams actually write this.
    var sourceFiles = Directory.GetFiles(
        Path.Combine(SolutionDirectory, "src"),
        "*.cs",
        SearchOption.AllDirectories);

    foreach (var file in sourceFiles)
    {
        var content = File.ReadAllText(file);
        content.Should().NotContain("Console.WriteLine",
            $"File {Path.GetFileName(file)} uses Console.WriteLine instead of ILogger");
        content.Should().NotContain("Debug.WriteLine",
            $"File {Path.GetFileName(file)} uses Debug.WriteLine instead of ILogger");
    }
}

The convention works. The documentation is clear. The enforcement tests catch gross violations.

The problems that remain:

  • The test checks that ILogger is injected. It does not check that it is used. A service can inject ILogger<T> and never call it.
  • The test checks that Console.WriteLine is absent. It does not check that structured logging is used correctly. _logger.LogInformation($"Order {orderId}") passes every test but allocates a string on every call — defeating the purpose of structured logging.
  • The wiki says "never log PII." The test does not check for PII. How could it? It would need to know which properties are PII.
  • The wiki says "log at entry and exit of public methods." Nobody checks this. It is a convention that exists only in prose.

Era 4: Contention — [LoggerMessage] and Custom Analyzers

.NET 6 introduced the [LoggerMessage] source generator — a built-in SG that generates high-performance log methods from partial method signatures. This is Contention for logging: declare the shape, and the compiler generates the implementation.

// Era 4: Contention — [LoggerMessage] source generator (built-in .NET 8+)
public partial class OrderService
{
    private readonly ILogger<OrderService> _logger;
    private readonly IOrderRepository _repository;

    public OrderService(ILogger<OrderService> logger, IOrderRepository repository)
    {
        _logger = logger;
        _repository = repository;
    }

    [LoggerMessage(Level = LogLevel.Information, EventId = 1001,
        Message = "Creating order for customer {CustomerId} with {ItemCount} items")]
    private partial void LogCreatingOrder(string customerId, int itemCount);

    [LoggerMessage(Level = LogLevel.Information, EventId = 1002,
        Message = "Order {OrderId} created successfully, total: {OrderTotal}")]
    private partial void LogOrderCreated(Guid orderId, decimal orderTotal);

    [LoggerMessage(Level = LogLevel.Error, EventId = 1003,
        Message = "Failed to create order for customer {CustomerId}")]
    private partial void LogOrderCreationFailed(Exception ex, string customerId);

    public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
    {
        LogCreatingOrder(request.CustomerId, request.Items.Count);

        var order = new Order { /* ... */ };

        try
        {
            await _repository.AddAsync(order);
            LogOrderCreated(order.Id, order.Total);
        }
        catch (Exception ex)
        {
            LogOrderCreationFailed(ex, request.CustomerId);
            throw;
        }

        return order;
    }
}

The [LoggerMessage] SG generates the actual method body. No params object[] allocation. No boxing of value types. No string interpolation. The generated code checks _logger.IsEnabled(LogLevel.Information) before doing any work — zero overhead when the level is disabled.

But [LoggerMessage] only solves the implementation side. It generates high-performance log methods. It does not enforce that every public method has logging. It does not prevent PII from being logged. It does not detect string interpolation in other _logger.Log* calls.

That is where custom analyzers complete the picture.

The Custom [Logged] Attribute and Analyzer Suite

// [Logged] attribute — marks a service class as requiring structured logging
[AttributeUsage(AttributeTargets.Class)]
public sealed class LoggedAttribute : Attribute { }

// [NotLogged] attribute — marks properties that must not appear in log parameters
[AttributeUsage(AttributeTargets.Property)]
public sealed class NotLoggedAttribute : Attribute { }

// PII model with [NotLogged] on sensitive properties
public record CreateOrderRequest
{
    public required string CustomerId { get; init; }
    public required List<OrderItem> Items { get; init; }

    [NotLogged]
    public required string CustomerEmail { get; init; }

    [NotLogged]
    public required string PaymentCard { get; init; }

    [NotLogged]
    public required string ShippingPhone { get; init; }
}
// [Logged] service — the SG generates entry/exit logging for public methods
[Logged]
public partial class OrderService
{
    private readonly ILogger<OrderService> _logger;
    private readonly IOrderRepository _repository;

    public OrderService(ILogger<OrderService> logger, IOrderRepository repository)
    {
        _logger = logger;
        _repository = repository;
    }

    public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
    {
        // Entry/exit logging is generated by the SG.
        // The developer writes only business logic.
        var order = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = request.CustomerId,
            Items = request.Items.Select(i => new LineItem
            {
                ProductId = i.ProductId,
                Quantity = i.Quantity,
                UnitPrice = i.UnitPrice
            }).ToList(),
            CreatedAt = DateTime.UtcNow
        };

        await _repository.AddAsync(order);
        return order;
    }
}
// ── Generated: OrderService.Logging.g.cs ──
// The SG generates a partial class with interceptors for entry/exit logging.
// Parameters marked [NotLogged] are excluded from structured log data.

[GeneratedCode("Cmf.Logging.Generators", "1.0.0")]
partial class OrderService
{
    [LoggerMessage(Level = LogLevel.Debug, EventId = 10001,
        Message = "Entering {MethodName} — CustomerId: {CustomerId}, ItemCount: {ItemCount}")]
    private partial void LogEnteringCreateOrderAsync(
        string methodName, string customerId, int itemCount);

    [LoggerMessage(Level = LogLevel.Debug, EventId = 10002,
        Message = "Exiting {MethodName} — Duration: {ElapsedMs}ms")]
    private partial void LogExitingCreateOrderAsync(
        string methodName, double elapsedMs);

    [LoggerMessage(Level = LogLevel.Error, EventId = 10003,
        Message = "Failed {MethodName} — Duration: {ElapsedMs}ms")]
    private partial void LogFailedCreateOrderAsync(
        Exception ex, string methodName, double elapsedMs);

    // Note: CustomerEmail, PaymentCard, ShippingPhone are [NotLogged]
    // and do NOT appear in any generated log parameters.
}

The SG reads the method signature, reads the parameter types, reads [NotLogged] annotations on properties, and generates entry/exit log methods that include only safe parameters. PII is structurally excluded — not by developer discipline, but by the type system.

The Analyzer Rules

// LOG001: Public method in [Logged] service lacks log statement
// Severity: Warning
// Trigger: A public method in a [Logged] class has no [LoggerMessage] call
//          and no manual _logger.Log* call.

warning LOG001: Method 'OrderService.GetOrderStatusAsync' has no log statements.
                Public methods in [Logged] services should have structured logging.
                The [Logged] SG generates entry/exit logs, but this method
                has been excluded with [SkipLogging]. Verify this is intentional.
                (OrderService.cs, line 47)
// LOG002: String interpolation in log call
// Severity: Error
// Trigger: A call to _logger.Log* uses string interpolation ($"...")
//          instead of structured logging placeholders.

error LOG002: Log call uses string interpolation:
              _logger.LogInformation($"Order {orderId} created")
              This allocates a string on every call, even when the log level
              is disabled. Use structured logging:
              _logger.LogInformation("Order {OrderId} created", orderId)
              Or better: use [LoggerMessage] source generator.
              (OrderService.cs, line 23)
// LOG003: PII property passed to log method
// Severity: Error
// Trigger: A property marked [NotLogged] is passed as a parameter
//          to any _logger.Log* call or [LoggerMessage] method.

error LOG003: Property 'CreateOrderRequest.CustomerEmail' is marked [NotLogged]
              but is passed to LogInformation at line 31.
              [NotLogged] properties must not appear in log output.
              Remove this parameter from the log call.
              (OrderService.cs, line 31)

What Disappears

No wiki/logging-standards.md        — [Logged] IS the standard
No LoggingConventionTests.cs        — LOG001-LOG003 enforce at compile time
No manual entry/exit boilerplate    — SG generates it with [NotLogged] filtering
No manual LoggerMessage.Define      — SG generates [LoggerMessage] methods
No PII review checklist item        — LOG003 catches it structurally
────────────────────────────────────
Convention overhead: 0 lines
Wiki pages: 0
Enforcement tests: 0

Era 1: Code — String-Based Role Checks

In the beginning, authorization was an if-statement. With a string.

// Era 1: Code — manual role checks in every controller
public class UserController
{
    private readonly IUserRepository _repository;

    public UserController(IUserRepository repository)
    {
        _repository = repository;
    }

    public IActionResult GetAllUsers(HttpContext context)
    {
        // Developer A checks roles like this
        if (context.User.Identity?.IsAuthenticated != true)
        {
            return new UnauthorizedResult();
        }

        if (!context.User.IsInRole("Admin"))
        {
            return new ForbidResult();
        }

        var users = _repository.GetAll();
        return new OkObjectResult(users);
    }

    public IActionResult DeleteUser(HttpContext context, int userId)
    {
        // Developer B uses a different string
        if (!context.User.IsInRole("Administrator"))  // "Admin" vs "Administrator" — which is right?
        {
            return new ForbidResult();
        }

        _repository.Delete(userId);
        return new OkResult();
    }

    public IActionResult UpdateUser(HttpContext context, int userId, UpdateUserRequest request)
    {
        // Developer C forgets the check entirely.
        // This endpoint is accessible to everyone.
        // Nobody notices until a penetration test — or a breach.
        var user = _repository.GetById(userId);
        user.Name = request.Name;
        user.Email = request.Email;
        _repository.Update(user);
        return new OkResult();
    }

    public IActionResult GetUserProfile(HttpContext context, int userId)
    {
        // Developer D checks authentication but not authorization.
        // Any authenticated user can view any other user's profile.
        if (context.User.Identity?.IsAuthenticated != true)
        {
            return new UnauthorizedResult();
        }

        var user = _repository.GetById(userId);
        return new OkObjectResult(user);
    }
}

What goes wrong:

  • "Admin" vs "Administrator" — a typo that is also a security vulnerability. No compile-time validation. No IntelliSense. Two strings, two behaviors, one confused developer.
  • Developer C forgets the check. UpdateUser is wide open. The code compiles. The tests pass (there are no authorization tests). The vulnerability ships to production.
  • Developer D checks authentication but not authorization. Vertical privilege escalation — any logged-in user can view any profile. The IsAuthenticated check provides a false sense of security.
  • Every controller repeats the same boilerplate. Copy-paste security. Miss one endpoint, and the security model has a hole.

Era 2: Configuration — XML Authorization Rules

ASP.NET Web Forms and early MVC used web.config for authorization rules. Windows Identity Foundation (WIF) added claims-based configuration. The security policy moved from code to XML.

<!-- web.config — authorization rules in XML -->
<configuration>
  <system.web>
    <authorization>
      <deny users="?" />  <!-- Deny anonymous users globally -->
    </authorization>
  </system.web>

  <location path="api/users">
    <system.web>
      <authorization>
        <allow roles="Admin,Manager" />
        <deny users="*" />
      </authorization>
    </system.web>
  </location>

  <location path="api/users/profile">
    <system.web>
      <authorization>
        <allow roles="Admin,Manager,User" />
        <deny users="*" />
      </authorization>
    </system.web>
  </location>

  <location path="api/admin">
    <system.web>
      <authorization>
        <allow roles="Admin" />
        <deny users="*" />
      </authorization>
    </system.web>
  </location>

  <location path="api/reports">
    <!-- Someone forgot to add authorization rules here. -->
    <!-- This path is unprotected. -->
    <!-- The XML doesn't warn about missing rules. -->
  </location>
</configuration>
<!-- WIF claims mapping — role-to-claims configuration -->
<system.identityModel>
  <identityConfiguration>
    <claimsAuthorizationManager type="MyApp.Security.CustomClaimsAuthManager, MyApp">
      <policy resource="api/users" action="GET">
        <claim claimType="http://schemas.microsoft.com/ws/2008/06/identity/claims/role"
               claimValue="Admin" />
      </policy>
      <policy resource="api/users" action="DELETE">
        <claim claimType="http://schemas.microsoft.com/ws/2008/06/identity/claims/role"
               claimValue="Admin" />
      </policy>
      <!-- 50 more policy entries, one per endpoint × HTTP method -->
    </claimsAuthorizationManager>
  </identityConfiguration>
</system.identityModel>

What goes wrong:

  • The XML maps URL paths to roles. The controller maps URL paths to methods. Two separate files, two separate truth sources. Rename a route? Update both files. Forget one? Silent security gap.
  • The api/reports path has no authorization rules. The XML does not warn about this. There is no "missing rule" detection. The absence of a rule is itself the vulnerability.
  • Claims configuration is 50+ entries long. Each entry is a URL path + HTTP method + role string. No IntelliSense. No compile-time validation. One typo in a claimValue and the policy silently fails.
  • The security policy is invisible to the code. A developer reading the controller has no idea what authorization rules apply. They must cross-reference the XML.

Era 3: Convention — Policy-Based Authorization

ASP.NET Core replaced the XML with attribute-based authorization and policy-based authorization. [Authorize] on a controller or action. Policies defined in Program.cs. Claims-based, role-based, or custom requirement-based.

// Era 3: Convention — policy-based authorization
[Authorize]
[ApiController]
[Route("api/users")]
public class UserController : ControllerBase
{
    private readonly IUserRepository _repository;

    public UserController(IUserRepository repository)
    {
        _repository = repository;
    }

    [HttpGet]
    [Authorize(Policy = "AdminOnly")]
    public IActionResult GetAllUsers()
    {
        return Ok(_repository.GetAll());
    }

    [HttpDelete("{id}")]
    [Authorize(Policy = "AdminOnly")]
    public IActionResult DeleteUser(int id)
    {
        _repository.Delete(id);
        return Ok();
    }

    [HttpPut("{id}")]
    [Authorize(Policy = "CanManageUsers")]
    public IActionResult UpdateUser(int id, UpdateUserRequest request)
    {
        var user = _repository.GetById(id);
        user.Name = request.Name;
        _repository.Update(user);
        return Ok();
    }

    [HttpGet("{id}/profile")]
    [Authorize(Policy = "CanViewProfiles")]
    public IActionResult GetUserProfile(int id)
    {
        return Ok(_repository.GetById(id));
    }
}
// Program.cs — policy registration
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", policy =>
        policy.RequireRole("Admin"));

    options.AddPolicy("CanManageUsers", policy =>
        policy.RequireRole("Admin", "Manager"));

    options.AddPolicy("CanViewProfiles", policy =>
        policy.RequireRole("Admin", "Manager", "User"));

    options.AddPolicy("CanViewReports", policy =>
        policy.RequireRole("Admin", "Analyst"));

    // 20 more policies...
});

Better. Policies are named. [Authorize] is declarative. Roles are centralized in policy definitions rather than scattered across controllers.

But now the team needs conventions.

The Documentation

<!-- wiki/security-standards.md — the document every team writes -->

# Security and Authorization Standards

## Required Practices
- ALL controller classes MUST have [Authorize] at class level
- ALL action methods MUST have a specific [Authorize(Policy = "...")] attribute
- [AllowAnonymous] MUST have a comment explaining why
- Policy names MUST match the permission they represent (e.g., "CanManageUsers")
- NEVER use role strings directly in [Authorize(Roles = "Admin")]
  - Bad:  [Authorize(Roles = "Admin")]
  - Good: [Authorize(Policy = "AdminOnly")]

## Permission Matrix
| Policy              | Admin | Manager | User | Analyst | Anonymous |
|---------------------|-------|---------|------|---------|-----------|
| AdminOnly           |   X   |         |      |         |           |
| CanManageUsers      |   X   |    X    |      |         |           |
| CanViewProfiles     |   X   |    X    |  X   |         |           |
| CanViewReports      |   X   |         |      |    X    |           |
| CanManageInventory  |   X   |    X    |      |         |           |

## New Endpoint Checklist
1. Add [Authorize(Policy = "...")] attribute
2. Create policy in Program.cs if new permission needed
3. Update permission matrix in this wiki
4. Add integration test verifying authorization

The Enforcement Code

// Tests to enforce authorization conventions
[Fact]
public void All_Endpoints_Should_Have_Authorize_Attribute()
{
    var controllerTypes = typeof(UserController).Assembly
        .GetTypes()
        .Where(t => typeof(ControllerBase).IsAssignableFrom(t) && !t.IsAbstract);

    foreach (var controller in controllerTypes)
    {
        var methods = controller.GetMethods(BindingFlags.Public | BindingFlags.Instance)
            .Where(m => m.GetCustomAttributes()
                .Any(a => a.GetType().Name.StartsWith("Http")));

        foreach (var method in methods)
        {
            var hasAuthorize = method.GetCustomAttribute<AuthorizeAttribute>() != null
                || controller.GetCustomAttribute<AuthorizeAttribute>() != null;
            var hasAllowAnonymous = method.GetCustomAttribute<AllowAnonymousAttribute>() != null;

            (hasAuthorize || hasAllowAnonymous).Should().BeTrue(
                $"Endpoint {controller.Name}.{method.Name} must have [Authorize] " +
                $"or [AllowAnonymous]");
        }
    }
}

[Fact]
public void All_Policies_Should_Be_Registered()
{
    var policyNames = typeof(UserController).Assembly
        .GetTypes()
        .SelectMany(t => t.GetMethods())
        .SelectMany(m => m.GetCustomAttributes<AuthorizeAttribute>())
        .Where(a => !string.IsNullOrEmpty(a.Policy))
        .Select(a => a.Policy!)
        .Distinct();

    var serviceProvider = CreateTestServiceProvider();
    var authOptions = serviceProvider.GetRequiredService<IOptions<AuthorizationOptions>>().Value;

    foreach (var policyName in policyNames)
    {
        var policy = authOptions.GetPolicy(policyName);
        policy.Should().NotBeNull(
            $"Policy '{policyName}' is referenced by an [Authorize] attribute " +
            $"but is not registered in AddAuthorization()");
    }
}

The convention works. The tests catch missing [Authorize] attributes and unregistered policies.

The problems that remain:

  • The test catches missing [Authorize] at test time. A developer can add a new endpoint, run the application, test it locally, push to the branch — all without the authorization test running. The gap between code change and detection is hours or days.
  • Policy names are strings. [Authorize(Policy = "AdminOnly")] compiles whether or not "AdminOnly" is registered. The string is invisible to the type system.
  • The permission matrix in the wiki is a manual artifact. It drifts from the actual policy definitions in Program.cs. Nobody updates the wiki when they add a role to a policy.
  • [AllowAnonymous] has no enforcement. The wiki says "must have a comment explaining why." The test does not check for comments.

Era 4: Contention — [RequiresPermission] and Source-Generated Policies

Contention replaces the string-based policy system with an enum-based permission system. One attribute. One enum. The SG generates the policies, the role mappings, and the middleware. The analyzer ensures nothing is missing.

// Permission enum — the single source of truth for all permissions
public enum Permission
{
    // User management
    ViewUsers,
    ManageUsers,
    DeleteUsers,

    // Profile access
    ViewOwnProfile,
    ViewAnyProfile,

    // Reports
    ViewReports,
    ExportReports,

    // Inventory
    ViewInventory,
    ManageInventory,

    // System
    ManageSettings,
    ViewAuditLog
}
// Role-to-permission mapping — declared once, used everywhere
[PermissionMapping]
public static class RolePermissions
{
    [Role("Admin")]
    public static Permission[] Admin =>
    [
        Permission.ViewUsers, Permission.ManageUsers, Permission.DeleteUsers,
        Permission.ViewOwnProfile, Permission.ViewAnyProfile,
        Permission.ViewReports, Permission.ExportReports,
        Permission.ViewInventory, Permission.ManageInventory,
        Permission.ManageSettings, Permission.ViewAuditLog
    ];

    [Role("Manager")]
    public static Permission[] Manager =>
    [
        Permission.ViewUsers, Permission.ManageUsers,
        Permission.ViewOwnProfile, Permission.ViewAnyProfile,
        Permission.ViewReports,
        Permission.ViewInventory, Permission.ManageInventory
    ];

    [Role("User")]
    public static Permission[] User =>
    [
        Permission.ViewOwnProfile,
        Permission.ViewInventory
    ];

    [Role("Analyst")]
    public static Permission[] Analyst =>
    [
        Permission.ViewOwnProfile,
        Permission.ViewReports, Permission.ExportReports
    ];
}
// Controller with [RequiresPermission] — type-safe, enum-based
[ApiController]
[Route("api/users")]
public class UserController : ControllerBase
{
    private readonly IUserRepository _repository;

    public UserController(IUserRepository repository)
    {
        _repository = repository;
    }

    [HttpGet]
    [RequiresPermission(Permission.ViewUsers)]
    public IActionResult GetAllUsers()
    {
        return Ok(_repository.GetAll());
    }

    [HttpDelete("{id}")]
    [RequiresPermission(Permission.DeleteUsers)]
    public IActionResult DeleteUser(int id)
    {
        _repository.Delete(id);
        return Ok();
    }

    [HttpPut("{id}")]
    [RequiresPermission(Permission.ManageUsers)]
    public IActionResult UpdateUser(int id, UpdateUserRequest request)
    {
        var user = _repository.GetById(id);
        user.Name = request.Name;
        _repository.Update(user);
        return Ok();
    }

    [HttpGet("{id}/profile")]
    [RequiresPermission(Permission.ViewAnyProfile)]
    public IActionResult GetUserProfile(int id)
    {
        return Ok(_repository.GetById(id));
    }
}
// ── Generated: PermissionPolicies.g.cs ──
// The SG generates policy registration from the enum + role mapping.

[GeneratedCode("Cmf.Security.Generators", "1.0.0")]
public static class PermissionPolicies
{
    public static IServiceCollection AddGeneratedPermissionPolicies(
        this IServiceCollection services)
    {
        services.AddAuthorization(options =>
        {
            // One policy per permission enum value — generated from Permission enum
            options.AddPolicy("Permission:ViewUsers", policy =>
                policy.RequireAssertion(context =>
                    context.User.HasClaim("permission", "ViewUsers")));

            options.AddPolicy("Permission:ManageUsers", policy =>
                policy.RequireAssertion(context =>
                    context.User.HasClaim("permission", "ManageUsers")));

            options.AddPolicy("Permission:DeleteUsers", policy =>
                policy.RequireAssertion(context =>
                    context.User.HasClaim("permission", "DeleteUsers")));

            // ... one policy per enum value, all generated
        });

        return services;
    }

    // Role-to-claims transformer — generated from [PermissionMapping]
    public static ClaimsTransformation CreateClaimsTransformer()
    {
        return new ClaimsTransformation(principal =>
        {
            var roles = principal.Claims
                .Where(c => c.Type == ClaimTypes.Role)
                .Select(c => c.Value);

            foreach (var role in roles)
            {
                var permissions = role switch
                {
                    "Admin" => new[]
                    {
                        "ViewUsers", "ManageUsers", "DeleteUsers",
                        "ViewOwnProfile", "ViewAnyProfile",
                        "ViewReports", "ExportReports",
                        "ViewInventory", "ManageInventory",
                        "ManageSettings", "ViewAuditLog"
                    },
                    "Manager" => new[]
                    {
                        "ViewUsers", "ManageUsers",
                        "ViewOwnProfile", "ViewAnyProfile",
                        "ViewReports",
                        "ViewInventory", "ManageInventory"
                    },
                    "User" => new[] { "ViewOwnProfile", "ViewInventory" },
                    "Analyst" => new[]
                    {
                        "ViewOwnProfile",
                        "ViewReports", "ExportReports"
                    },
                    _ => Array.Empty<string>()
                };

                foreach (var permission in permissions)
                {
                    principal.AddClaim("permission", permission);
                }
            }
        });
    }
}
// ── Generated: PermissionMatrix.g.cs ──
// The SG generates the permission matrix as code — no wiki needed.

[GeneratedCode("Cmf.Security.Generators", "1.0.0")]
public static class PermissionMatrix
{
    /// <summary>
    /// Generated permission matrix. Use in tests, documentation, or admin UI.
    /// </summary>
    public static readonly IReadOnlyDictionary<Permission, string[]> RolesByPermission =
        new Dictionary<Permission, string[]>
        {
            [Permission.ViewUsers] = ["Admin", "Manager"],
            [Permission.ManageUsers] = ["Admin", "Manager"],
            [Permission.DeleteUsers] = ["Admin"],
            [Permission.ViewOwnProfile] = ["Admin", "Manager", "User", "Analyst"],
            [Permission.ViewAnyProfile] = ["Admin", "Manager"],
            [Permission.ViewReports] = ["Admin", "Manager", "Analyst"],
            [Permission.ExportReports] = ["Admin", "Analyst"],
            [Permission.ViewInventory] = ["Admin", "Manager", "User"],
            [Permission.ManageInventory] = ["Admin", "Manager"],
            [Permission.ManageSettings] = ["Admin"],
            [Permission.ViewAuditLog] = ["Admin"],
        };
}

The permission matrix is generated from the [PermissionMapping] class. It cannot drift from the actual policy definitions because it is the policy definitions. The wiki page is obsolete. The spreadsheet is obsolete. The single source of truth is the RolePermissions class, and the SG generates everything else from it.

The Analyzer Rules

// SEC001: Endpoint method lacks authorization attribute
// Severity: Error
// Trigger: A public method with [Http*] attribute has no [RequiresPermission],
//          [Authorize], or [AllowAnonymous] attribute.

error SEC001: Endpoint 'ReportController.ExportToCsv' has no authorization attribute.
              All endpoint methods must have [RequiresPermission(Permission.X)],
              [Authorize], or [AllowAnonymous].
              This is a compile error, not a warning. Unprotected endpoints
              do not ship.
              (ReportController.cs, line 42)
// SEC002: [AllowAnonymous] without justification
// Severity: Warning
// Trigger: A method has [AllowAnonymous] but no // ALLOW_ANONYMOUS: reason comment.

warning SEC002: Endpoint 'AuthController.Login' uses [AllowAnonymous] without
                a justification comment. Add a comment above the attribute:
                // ALLOW_ANONYMOUS: Login endpoint must be accessible without authentication
                [AllowAnonymous]
                This is tracked for security review.
                (AuthController.cs, line 15)
// SEC003: Permission enum value not mapped to any role
// Severity: Warning
// Trigger: A Permission enum value exists but does not appear in any
//          [Role] array in the [PermissionMapping] class.

warning SEC003: Permission 'ViewAuditLog' is defined in the Permission enum
                but is not assigned to any role in RolePermissions.
                No user can ever be granted this permission.
                Add it to at least one role's permission array,
                or remove it from the enum if it is obsolete.
                (Permission.cs, line 14)

SEC001 is the critical one. It is a compile error, not a warning. An endpoint without an authorization attribute does not compile. The gap between "developer adds endpoint" and "authorization is enforced" is zero. Not "next test run." Not "next CI build." Zero. The code does not compile.

What Disappears

No wiki/security-standards.md            — [RequiresPermission] IS the standard
No permission-matrix.xlsx                — SG generates PermissionMatrix.g.cs
No AuthorizationConventionTests.cs       — SEC001-SEC003 enforce at compile time
No manual policy registration            — SG generates AddGeneratedPermissionPolicies()
No manual claims transformation          — SG generates CreateClaimsTransformer()
No string-based policy names             — enum Permission replaces strings
────────────────────────────────────────
Convention overhead: 0 lines
Wiki pages: 0
Spreadsheets: 0
Enforcement tests: 0

The Generated Permission Matrix

The SG generates this from the [PermissionMapping] class. It replaces the wiki spreadsheet.

Diagram

This matrix is not a wiki page. It is not a spreadsheet. It is generated code. Add a permission to the enum? The matrix updates. Add a role mapping? The matrix updates. Remove a permission? The matrix updates, and SEC003 warns if the permission is still referenced.


The Cross-Cutting Convention Tax — Combined

Both domains follow the same pattern. Both domains have the same overhead. Here is the combined cost.

Artifact Logging (Convention) Logging (Contention) Security (Convention) Security (Contention)
Wiki / documentation 30 lines (standards) 0 40 lines (standards + matrix) 0
Enforcement tests 40 lines (2 tests) 0 50 lines (2 tests) 0
Per-class boilerplate Entry/exit logging in every method 0 (SG generates) [Authorize(Policy="...")] on every endpoint 0 (SG generates policies)
Registration code Serilog config, enrichers 0 Policy definitions in Program.cs 0 (SG generates)
PII/Security matrix Code review checklist [NotLogged] structural Excel spreadsheet SG generates PermissionMatrix.g.cs
String-based references Event ID conventions [LoggerMessage] with typed IDs Policy name strings Permission enum
Detection of missing coverage Test time (partial) Compile time (LOG001) Test time Compile time (SEC001)
Detection of violations Code review Compile time (LOG002, LOG003) Code review Compile time (SEC002, SEC003)
New class/endpoint workflow Add logging manually, follow wiki Add [Logged]. Done. Add [Authorize], update wiki, update matrix Add [RequiresPermission]. Done.
Onboarding time 1 hour (read wiki, study patterns) 0 — attribute is self-explanatory 1-2 hours (read wiki, study matrix) 0 — enum is self-explanatory

The pattern is identical. Convention requires documentation + enforcement code. Contention replaces both with attributes + source generation + analyzers.

The cost is not just the lines of code. The cost is the cognitive load on every developer for every change. "Did I add logging to this method?" "Did I add authorization to this endpoint?" "Is the policy name spelled correctly?" "Did I update the wiki?" These questions disappear when the compiler answers them.


The Structural Difference

The Convention era tells developers what to do and then checks if they did it. The Contention era makes the wrong thing impossible and generates the right thing automatically.

For logging:

  • Convention says: "Log at entry and exit of public methods. Never log PII."
  • Contention says: [Logged] generates entry/exit logging. [NotLogged] excludes PII structurally. LOG003 refuses to compile if PII leaks into a log call.

For security:

  • Convention says: "All endpoints must have [Authorize]. Use policies, not roles. Update the permission matrix."
  • Contention says: [RequiresPermission(Permission.X)] generates the policy. SEC001 refuses to compile without it. The permission matrix is generated from the role mapping.

The verbs change:

Convention Contention
Documents Generates
Polices Guards
Hopes Guarantees
Tests for presence Compiles only with presence
Catches at test time Catches at compile time
Drifts from reality IS reality

Cross-References

This pattern connects directly to another post:

  • Don't Put the Burden on Developers — The core argument applies with extra force to cross-cutting concerns. If "every public method must have logging" matters, don't document it in a wiki and hope developers read it. If "every endpoint must have authorization" matters, don't write a test that catches violations hours later. Make the compiler enforce both — because the cost of forgetting is not a code review comment, it is a security breach or an invisible production failure.

Closing

Logging and security are the two domains where Convention's cost is most dangerous, because the consequence of a missed convention is not a code quality issue — it is a production incident. A missing log statement makes the next incident uninvestigable. A missing authorization check makes the next incident possible.

The pattern across both domains is identical:

  • Era 1 scatters the concern through manual code. Inconsistent, incomplete, error-prone.
  • Era 2 moves the concern to XML configuration. Two truth sources. Drift.
  • Era 3 provides framework abstractions and documents conventions. Better, but the documentation and enforcement code are overhead that scales with surface area.
  • Era 4 declares intent with attributes, generates implementation with Source Generators, and refuses to compile violations with analyzers. Zero documentation. Zero enforcement tests. Zero drift.

Convention documents and polices. Contention generates and guards.

For cross-cutting concerns, where the surface area is the entire application, the difference between "documents and polices" and "generates and guards" is not an efficiency improvement. It is the difference between a security posture that depends on human discipline and one that is structurally guaranteed.

Next: Part XI — where we synthesize the pattern across all ten domains and measure the total Convention Tax that Contention eliminates.