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

Security, Authorization & Audit

Every CMS sooner or later confronts the same three questions: who is allowed to do this?, who actually did this, and when?, and can a tenant ever see another tenant's data?. These are the questions that, in a typical .NET project, scatter across [Authorize] attributes on controllers, ad-hoc if (user.IsInRole(...)) checks inside services, hand-rolled audit interceptors, and a WHERE TenantId = @t filter that one developer forgets to add to one query.

The CMF approach is to make all three concerns declarations on the same attributes that drive the rest of the generators. A single [RequiresRole("Editor")] on a workflow transition becomes a controller [Authorize(Policy = "Editor")], a Blazor admin button visibility check, an audit log entry on success and failure, and a row-level filter when the tenant scope is in play. The propagation is mechanical, which means the developer cannot accidentally protect the API and forget to hide the button — both come from the same source.

The Three Layers of Authorization

The CMF does not invent a new authorization model. It uses the standard ASP.NET Core triad — authentication (who you are), claims (what you have), and policies (what is required) — and decorates the existing DSLs with attributes that compile into all three.

Layer Source Where it ends up
Authentication Configured in Startup.cs (OIDC, cookies, JWT) Server only, not generated
Claims hydration Hand-written IClaimsTransformation reading from the user store Runs once per request, populates ClaimsPrincipal
Policy declarations [RequiresRole], [RequiresPolicy], [RequiresClaim] on DSL attributes Generated AuthorizationPolicyProvider
Enforcement points The generated controller, Blazor button, workflow gate, audit interceptor Stage 3 + Stage 4 outputs

The "policy declarations" row is the interesting one because it is the place where the developer's intent enters the system. Everything below it is mechanical.

[RequiresRole] on a Workflow Transition

Recall the Approve transition from Part 12:

[Transition(From = "Review", To = "Approved", Label = "Approve")]
[RequiresRole("Reviewer")]
[Gate("AllAcceptanceCriteriaMet")]
public partial Result Approve(Product p);

That single [RequiresRole("Reviewer")] propagates to six generated touch points:

  1. EditorialWorkflow.StateMachine.g.cs — the Approve method's first line is if (!actor.HasRole("Reviewer")) return Result.Failure("AUTH-001", "Reviewer role required");. This is the innermost check; nothing else in the system can bypass it.

  2. ProductsController.g.cs — the POST /api/products/{id}/workflow/transition endpoint has [Authorize(Policy = "Workflow.Editorial.Approve")] decoration. The policy itself is registered automatically by the generated MyStoreAuthorizationOptions.

  3. ProductDetail.razor.g.cs — the Approve action button is wrapped in <AuthorizeView Policy="Workflow.Editorial.Approve"> so users without the role do not even see it.

  4. OrdersGraphQLType.g.cs — the GraphQL approveOrder mutation has [Authorize(Policy = "Workflow.Editorial.Approve")] applied at the field level.

  5. MyStoreAuthorizationOptions.g.cs — a single source of truth that registers every generated policy:

    public static class MyStoreAuthorizationOptions
    {
        public static void Configure(AuthorizationOptions o)
        {
            o.AddPolicy("Workflow.Editorial.Approve",
                b => b.RequireRole("Reviewer"));
            o.AddPolicy("Workflow.Editorial.Publish",
                b => b.RequireRole("Publisher"));
            o.AddPolicy("Admin.Products.Edit",
                b => b.RequireRole("Editor", "Reviewer"));
            // ...one entry per [RequiresRole] / [RequiresPolicy] in the solution
        }
    }
  6. AuditInterceptor.g.cs — every call to Approve is wrapped so that both the success and the rejection are logged with the actor's identity, the role check outcome, and the workflow transition. This is detailed in the audit section below.

The crucial property is that points 1 through 5 are derived from the same attribute. A developer who renames "Reviewer" to "ContentReviewer" updates one declaration; the next cmf generate cascades the change through the controller, the button, the GraphQL field, the policy registration, and the audit log keys. There is no place to forget.

[RequiresPolicy] for Claims-Based Rules

Roles are the simple case. Real authorization usually wants something like "only the user who owns the order, or an admin, can cancel it". The CMF supports this with [RequiresPolicy] plus a hand-written policy handler:

[Command("CancelOrder")]
[RequiresPolicy("OrderOwnerOrAdmin")]
public partial record CancelOrderCommand(OrderId Id, string Reason);

The generator emits the policy reference, but the handler lives in MyStore.Server/Authorization/OrderOwnerOrAdminHandler.cs and is hand-written:

public sealed class OrderOwnerOrAdminHandler
    : AuthorizationHandler<OrderOwnerOrAdminRequirement, OrderId>
{
    private readonly IOrderRepository _orders;
    public OrderOwnerOrAdminHandler(IOrderRepository orders) { _orders = orders; }

    protected override async Task HandleRequirementAsync(
        AuthorizationHandlerContext ctx,
        OrderOwnerOrAdminRequirement req,
        OrderId resource)
    {
        if (ctx.User.IsInRole("Admin")) { ctx.Succeed(req); return; }
        var order = await _orders.GetAsync(resource);
        var userId = ctx.User.GetUserId();
        if (order is not null && order.CustomerId.Value == userId)
            ctx.Succeed(req);
    }
}

The handler is registered once in DI; the policy itself is registered by the generated MyStoreAuthorizationOptions. The split is intentional: the declaration of which command needs which policy is generated (so the surface stays in sync), but the evaluation of business-specific logic stays hand-written (because no generator can guess what "owner" means).

[RequiresClaim] for Tenant or Region Filters

Some checks are not roles or policies but raw claim presence:

[AdminModule("EuropeOrders", Aggregate = "Order")]
[RequiresClaim("region", "EU")]
public partial class EuropeOrdersAdminModule { }

This emits [Authorize(Policy = "Claim.region.EU")] on the admin controller and the corresponding <AuthorizeView Resource="EU"> on the menu entry. The policy itself is b => b.RequireClaim("region", "EU"), registered automatically. Combining [RequiresClaim] with [TenantScoped] (next section) is the standard way to build region-specific or partner-specific dashboards on top of a single deployment.

Multi-Tenant Isolation

Multi-tenancy in the CMF is opt-in: an aggregate is not tenant-scoped unless it carries [TenantScoped]. Once it does, four things happen automatically:

  1. The generated EF configuration adds a TenantId column with a non-null constraint and a B-tree index.
  2. The generated OrderRepository injects ITenantContext and applies WHERE tenant_id = @currentTenant to every query — list, get, count, exists. There is no "raw" overload that escapes the filter; the only way to read across tenants is to call IAdminTenantBypass.WithoutFilter(...), which is in a separate assembly that production code does not reference.
  3. The generated CreateOrderCommandHandler populates TenantId from ITenantContext.Current on save; an attempt to construct an Order without a tenant context returns Result.Failure("TENANT-001").
  4. The generated audit trail records the tenant ID alongside every command, so a cross-tenant leak — should one ever occur — is forensically traceable.
[AggregateRoot("Order", BoundedContext = "Ordering")]
[TenantScoped]
public partial class Order { /* ...as before... */ }

Crucially, the CMF311 analyzer scans for any LINQ query that touches a [TenantScoped] aggregate's DbSet outside of a generated repository and emits an error. The hand-written escape hatches in MyStore.Server/RawQueries.cs must be explicitly annotated with [TenantBypass(Reason = "...")], which makes them reviewable as a class.

Audit Trail

Every command and every workflow transition produces an audit entry. The schema is fixed:

public sealed record AuditEntry(
    Guid Id,
    DateTimeOffset OccurredAt,
    string ActorId,
    IReadOnlyList<string> ActorRoles,
    Guid? TenantId,
    string AggregateType,
    string AggregateId,
    string Operation,                  // e.g. "CreateOrder", "Workflow.Approve"
    string Outcome,                    // Success | Failure | Unauthorized
    string? FailureCode,
    string? FailureMessage,
    JsonDocument PayloadHash,          // SHA-256 of the redacted payload
    IReadOnlyList<string> Implements,  // requirement IDs from [Implements]
    long DurationMicroseconds);

The CMF emits a AuditInterceptor.g.cs per bounded context that wraps every ICommandHandler<T,R> registered in DI. The interceptor:

  • Captures the actor identity from IHttpContextAccessor (or a test-time fake).
  • Records the start timestamp with a Stopwatch.
  • Invokes the inner handler.
  • On success, builds an AuditEntry with the requirement tags from the handler's [Implements] attributes.
  • On failure, records the same entry with Outcome = "Failure" and the typed failure code.
  • On UnauthorizedAccessException, records Outcome = "Unauthorized" before the exception is rethrown — important because failed authorization is a security signal that must be auditable even when the request is rejected.

The payload is hashed rather than stored verbatim. This is a deliberate trade-off: full payload audit is GDPR-hostile (every audit entry becomes personal data) but a hash lets a forensic investigation correlate "this exact request body was seen at time X with outcome Y" without retaining the body itself.

The audit log itself goes to a separate audit.entries table that is not tenant-scoped (the audit is global by definition) and has a retention policy enforced by a Hangfire job:

[BackgroundJob("AuditRetention", Cron = "0 3 * * *")]
[Implements(Requirements.FEATURE_180)]
public sealed class AuditRetentionJob
{
    public async Task RunAsync()
    {
        await _audit.PurgeOlderThan(DateTimeOffset.UtcNow.AddDays(-365));
    }
}

The [Implements] attribute matters: a feature like FEATURE-180 = "GDPR-compliant audit retention" is now wired to the job, the test that proves the job purges, and the report that confirms compliance.

Generated Permission Matrix

A side effect of having every authorization rule declared as an attribute is that the CMF can produce a complete permission matrix from a single command:

$ cmf report permissions
  ✓ artifacts/reports/permissions.md

  Catalog
  ───────
  GET    /api/products                       (anonymous)
  GET    /api/products/{id}                  (anonymous)
  POST   /api/products                       Admin.Products.Edit         [Editor]
  PUT    /api/products/{id}                  Admin.Products.Edit         [Editor]
  DELETE /api/products/{id}                  Admin.Products.Delete       [Admin]
  POST   /api/products/{id}/workflow/Submit  Workflow.Editorial.Submit   [Editor]
  POST   /api/products/{id}/workflow/Approve Workflow.Editorial.Approve  [Reviewer]
  POST   /api/products/{id}/workflow/Publish Workflow.Editorial.Publish  [Publisher]

  Ordering
  ────────
  POST   /api/orders                         (authenticated)
  POST   /api/orders/{id}/cancel             OrderOwnerOrAdmin           [Admin or owner]
  ...

This document is generated, not maintained. Auditors and security reviewers consume it directly; the developer never edits it. When a [RequiresRole] is added or removed, the matrix updates on the next build, and a reviewer can spot the diff in the PR.

Failure Modes the CMF Prevents

Failure mode in conventional CMS work How the CMF avoids it
Forgetting [Authorize] on a new controller action The action does not exist; the controller is generated
Hiding a button in the UI but leaving the API open The same [RequiresRole] drives both — they move together
Bypassing the tenant filter in a "quick" raw SQL query CMF311 analyzer flags any cross-tenant access not marked [TenantBypass]
Logging payloads that contain PII The generator hashes the payload by default; logging the raw body requires [AuditPayloadVerbatim] and an analyzer-approved comment
Audit log gaps when a command throws The interceptor wraps the handler in try/finally and records Outcome = "Crashed" with the exception type
Stale role names after a rename The [RequiresRole] attribute is the only place the string lives; renaming cascades through Stage 3

What Stays Hand-Written

This part has emphasized what the generators handle, but the boundaries are equally important. Hand-written code is required for:

  • Authentication wiring — OIDC clients, JWT validation, cookie schemes. The CMF does not generate Startup.cs.
  • Claims transformation — pulling claims from a user store, mapping LDAP groups to role names, etc. This is IClaimsTransformation and lives in the server project.
  • Custom policy handlers — anything more nuanced than RequireRole / RequireClaim is a hand-written AuthorizationHandler<T>.
  • Tenant resolution — deciding which tenant the current request belongs to (subdomain? JWT claim? path prefix?) is a hand-written ITenantResolver.
  • PII redaction policies — specifying that field customer.SocialSecurityNumber is redacted from the audit hash requires a hand-written IAuditPayloadRedactor.

These boundaries are not arbitrary. They are the places where the business makes a non-obvious choice that no generator could infer from the model. The CMF generates the mechanical parts and stays out of the policy parts.

⬇ Download