Endpoints Declared as Data
A four-field record that makes it impossible to register a route without stating who may call it - the best idea in the mini-framework - and the one line it did not write, which costs every 404 in the estate.
Part 15 was about a convention with no enforcement point. This part is the opposite: a design where the enforcement point is a constructor parameter, and the compiler does the auditing.
It is the best idea in GroupFlights' shared mini-framework, and it is worth extracting whole.
Four fields
// src/Shared/GroupFlights.Shared.ModuleDefinition/EndpointRegistration.cs:3-24
public record EndpointRegistration(
string Pattern,
HttpVerb HttpVerb,
NaiveAccessControl AccessControl,
Delegate Handler);
public enum HttpVerb
{
GET,
POST,
PUT,
DELETE
}
public enum NaiveAccessControl
{
Anonymous = 0,
ClientOnly = 1,
CashierOnly = 2,
ClientAndCashier = ClientOnly | CashierOnly,
AdministratorOnly = 64
}
A module's entire contract is three abstract members — a prefix, a DI hook, and a method returning a collection of those records. No lifecycle, no ordering, no priority, no Configure(IApplicationBuilder). An endpoint is a route pattern, a verb, an access level and a handler.
The payoff is AccessControl being a required positional parameter. You cannot construct an EndpointRegistration without stating who may call it. Not “you should remember to”; you cannot compile without it.
// src/Inquiries/GroupFlights.Inquiries.Api/InquiriesModule.cs:28-38
new EndpointRegistration(
"airports",
HttpVerb.GET,
NaiveAccessControl.Anonymous,
async (
[FromServices] IIataAirportService airportsService,
[FromQuery] string queryPhrase,
CancellationToken cancellationToken) =>
{
return await airportsService.GetAirportsByQueryPhrase(queryPhrase, cancellationToken);
}),
Now consider what a security review of this codebase looks like. “Which Inquiries endpoints are anonymous?” is answered by reading one method — a five-element array literal — and being done. Not a repo-wide grep for [AllowAnonymous], not a hunt through controller-level attributes overridden at action level, not a check of whether a fallback policy is configured. One method, one screen, complete.
Because the access decision is colocated with the route as a required parameter, the audit is complete by construction: no endpoint in the estate has an unstated access level. That is a real improvement on ASP.NET Core's default posture, where the absence of an attribute means “whatever the fallback policy says”, and where the fallback policy is defined somewhere else entirely.
The distribution falls out readable, too. Anonymous covers exactly the two things a prospective customer does before they exist as a user — search airports, submit an inquiry. CashierOnly covers back-office actions. ClientOnly covers customer-side actions. ClientAndCashier covers shared reads. And /communication registers no endpoints at all, so a module that is deliberately not an HTTP surface says so by returning an empty array.
The same three-member contract also hosts a one-file CRUD module and a four-project Clean Architecture module without change. That is decent evidence the abstraction is right-sized.
The one line that was not written
// src/Shared/GroupFlights.Shared.ModuleDefinition/RegistrationExtensions.cs:36-42
var endpointRoute = module.GetModulePrefixSanitized() + endpoint.WithLeadingSlash().Pattern;
RouteToNaiveAccessControl.Add(new RegistrationRouteKey(endpointRoute, endpoint.HttpVerb), endpoint.AccessControl);
endpointRouteBuilder.MapMethods(
pattern: endpointRoute,
httpMethods: new[] { endpoint.HttpVerb.ToString().ToUpper() },
handler: endpoint.Handler);
The route string is built once and used twice: as the ASP.NET Core route pattern, and as the key into a static dictionary holding the access level.
MapMethods returns an IEndpointConventionBuilder. A single chained .WithMetadata(endpoint.AccessControl) would have attached the access level to the endpoint itself — visible to context.GetEndpoint().Metadata, to Swagger operation filters, to any future policy provider. Instead the value goes into a side table keyed by a string that the middleware later has to reconstruct:
// src/Api/GroupFlights.Api/UserContext/NaiveAccessControlMiddleware.cs:19-25
var routePattern = (context.GetEndpoint() as RouteEndpoint)?.RoutePattern;
Enum.TryParse<HttpVerb>(context.Request.Method, out var httpVerb);
var routeKey = new RegistrationRouteKey(routePattern?.RawText, httpVerb);
var requiredAccess = Modules.ResolveAccessFor(routeKey);
It works — MapMethods stores the pattern verbatim, so RawText matches what was registered. But it works by coincidence of framework behaviour, and that one unwritten line is the direct cause of the next three findings.
Every 404 in this estate is a 500
ResolveAccessFor is a raw dictionary indexer:
public static NaiveAccessControl ResolveAccessFor(RegistrationRouteKey route)
{
return RouteToNaiveAccessControl[route];
}
For an unmatched request there is no endpoint, so routePattern is null, the key is RegistrationRouteKey(null, GET), and the indexer throws KeyNotFoundException. That is not a HumanPresentableException, so the error middleware logs it and returns:
HTTP 500, body
{"errorMessage":"Coś poszło nie tak!"}
A mistyped URL. A stale GUID. A path from an older version of the API. All of them return 500 with an opaque Polish message. The estate cannot produce a 404 at all — Swagger escapes only because UseSwaggerUI short-circuits its own paths first.
Recall Part 14: the most common onboarding mistake is asking for a resource before the five-second cascade has created it. That mistake and this bug meet, and the reader concludes the repository is broken.
The fix is one character — TryGetValue — or one line, back in MapModulesEndpoints, attaching metadata instead of populating a dictionary.
The verb parse that discards its own result
Enum.TryParse<HttpVerb>(context.Request.Method, out var httpVerb);
The return value is dropped. HttpVerb.GET = 0, and TryParse writes the default on failure, so every unparseable verb silently becomes GET: PATCH, HEAD, OPTIONS, TRACE. A CORS preflight is evaluated against whatever policy that route's GET declared. ignoreCase is also not passed, though ASP.NET Core normalises methods to upper case so that one is currently harmless.
This is downstream of the same decision. If the access level were endpoint metadata, there would be no verb to re-parse, because the endpoint the router already matched carries its own method constraint.
A published enum value guaranteed to fail
private bool IsAuthorized(IUserContext userContext, NaiveAccessControl requiredAccess)
{
switch (requiredAccess)
{
case NaiveAccessControl.CashierOnly:
return userContext.IsCashier;
case NaiveAccessControl.ClientOnly:
return userContext.IsClient;
case NaiveAccessControl.ClientAndCashier:
return userContext.IsCashier || userContext.IsClient;
default:
throw new NotSupportedException($"{nameof(NaiveAccessControl)}='{requiredAccess.ToString()}'");
}
}
AdministratorOnly = 64 has no case. Any module declaring it hits default, throws NotSupportedException, and returns 500 for every request to that route. Zero endpoints use it today, so the bug is latent — but the enum value is published framework API, offered to module authors, guaranteed to fail.
Three more things in that switch:
- The enum looks like flags and is not.
ClientAndCashier = ClientOnly | CashierOnlyis written in flags style, there is no[Flags]attribute, and matching is by exact value.AdministratorOnly = 64breaks the bit progression anyway — a flags enum would need4. The type invites combination, does not support it, and nothing enforces that. IsCashierimpliesIsClient(IsCashier => UserId is not null && CashierId is not null), so the||on theClientAndCashierarm is decorative.Anonymousis handled before the switch, sodefaultcovers onlyAdministratorOnlyand out-of-range casts.
And the trap on the identity side
IsAdministrator is set from header presence:
IsAdministrator = isAdminFound
X-IsAdmin: false makes you an administrator. The parsed value is captured into isAdmin and never read — and nothing anywhere reads IsAdministrator either, so the flag is dead in both directions. Combined with the missing AdministratorOnly case, the entire administrator concept exists as three fragments that never meet.
One more, and it is the sharpest: X-UserId: 00000000-0000-0000-0000-000000000000 authenticates as a client. UserId defines an implicit operator with a Guid.Empty → null guard, and NaiveUserContext calls the constructor directly, bypassing it. The record's own safety net is defeated by the one call site that most needed it.
The honest grade
The README says plainly that this is a naive imitation of authentication and authorisation, not the real thing, and that framing earns most of these a pass. A teaching estate that shipped real JWT plumbing would have spent a hundred lines teaching nobody anything about DDD.
But the naive auth is doing more work than “we skipped auth”. It is what makes the twenty-file .http scenario collection possible — no token acquisition, no login, no refresh, no variable chain, so accept-offer.http is four lines you can paste and run. The fake auth exists so that the scenario collection can be trivial, which is a deliberate developer-experience trade, and it compensates for a Swagger surface that cannot authenticate at all: AddSwaggerGen() is called with zero configuration, so there is no security definition, no header input, and “Try it out” returns 403 on everything but the two anonymous endpoints. The .http files are the API explorer.
So the grade is split cleanly. The idea — access control declared as data beside the route, as a required parameter — is excellent and portable. Steal it. The execution has one structural mistake, which is putting that data in a side dictionary instead of endpoint metadata, and every defect in this post descends from it: the 500-instead-of-404, the discarded verb parse, and a Swagger document that shows every endpoint as equally open because there is nothing attached for it to render.
One line, not written, three years ago.
Next, sixteen concepts, copied once — the residue left behind when Postsale was carved out of Sales, and what each divergence between the twins reveals.