Security Theatre and Security Reality
Pacco names every control - JWT validation, a Vault, a log scrub-list, admin-only routes - and then a sign-up body chooses its own role, the signing key lives in every repo, Vault is off in Docker and anonymous callers pass every ownership check. A governance read of which shortcuts survive copy-paste.
The correlation spine ships identity around the estate: role, permissions, an IsAuthenticated flag, all minted at the edge and trusted everywhere behind it. This part audits what that trust rests on — and it needs its frame stated first. Pacco is a teaching sample; judging its security posture as if it were production would be both unfair and uninteresting. What is interesting, operationally and for governance, is the gap between the controls the estate names and the controls that actually gate anything — because samples are what production systems are copied from, and it is precisely the named-but-hollow control that survives the copy. Theatre first, then reality, finding by finding.
The sign-up body that chooses its own role
Identity's SignUp command carries five properties: UserId, Email, Password, Role, and Permissions — an IEnumerable<string>. All five come from the caller. IdentityService.SignUpAsync defaults a blank role to "user", lowercases whatever else arrives, and the User constructor validates it with Role.IsValid — which accepts exactly two values, user and admin. Nothing anywhere restricts who may say admin. The estate documents the consequence itself: the committed Pacco.rest walkthrough signs up admin-user1@mailinator.com with the role set to admin in the request body, as casually as choosing a display name.
Permissions is the quieter half. It is stored on the user verbatim and, at sign-in, copied verbatim into the JWT:
var claims = user.Permissions.Any()
? new Dictionary<string, IEnumerable<string>>
{
["permissions"] = user.Permissions
}
: null;
var auth = _jwtProvider.Create(user.Id, user.Role, claims: claims);
Caller-supplied authorisation, end to end: the party being authorised writes its own permission list at registration and receives it back, signed, in every token thereafter. Meanwhile the theatre layer above it looks entirely respectable — the Ntrada gateway guards its admin routes with claims: role: admin, sets auth: true per route, and even validates the token issuer (validateIssuer: true), which the services behind it individually don't (validateIssuer: false in every service's jwt block). The gate checks the badge carefully; the badge printer is in the lobby.
The ownership check that anonymous callers walk past
Down in the domain services, ownership is enforced by one idiom, byte-identical in Availability's ReserveResourceHandler and six command handlers in Orders:
if (identity.IsAuthenticated && identity.Id != order.CustomerId && !identity.IsAdmin)
{
throw new UnauthorizedOrderAccessException(command.OrderId, identity.Id);
}
Read the boolean slowly: the check only applies to authenticated callers. A request that arrives with no identity at all — IsAuthenticated false — short-circuits the whole condition and proceeds as if it owned everything. The services trust the gateway to have populated the correlation context; called directly, over the compose network or a port mapping, every anonymous caller is a superuser. I have taken to calling this the anonymous-superuser idiom, and it is worth naming because it is not a typo — it is a deliberate trust posture (edge authenticates, interior believes) written in a way that fails open instead of closed. The honest version of the same posture is one clause shorter: if the interior genuinely trusts the edge, drop the check; if it does not, the check must start with !identity.IsAuthenticated throwing first. The middle position — a check that evaporates for exactly the callers most worth checking — is theatre with a code smell for a script.
Custody of the keys
The estate's tokens are signed with a symmetric key, and the key is committed. The same eighty-character string sits in the jwt.issuerSigningKey of every service's appsettings.json in every repo — which means two things at once: the key's custody is “everyone who has ever cloned any repo”, and every service that can verify a token can also mint one, because symmetric verification is issuance capability. The config gestures at the stronger design: Identity's jwt.certificate block points at certs/localhost.pfx with its password in the adjacent field, and the PFX, PEM and private-key files are committed to the repo under src/Pacco.Services.Identity.Api/certs/. But the Docker configuration blanks the certificate fields and keeps the symmetric key, so the shipped posture is: asymmetric crypto present as artefacts, symmetric key doing the work, both in source control. As patterns, not values, this is the whole secrets-custody syllabus in one repo — a committed symmetric key is not a weak secret, it is a published one; and a committed private key next to a config that names its password is the same finding twice.
The supporting cast is consistent. RabbitMQ runs on its stock guest/guest account; the compose Mongo has its root-credential environment block commented out entirely, so the database is unauthenticated; Seq's API key and the Vault dev root token are both the literal word “secret” in infrastructure.yml. Named here as findings, not as configuration to reuse.
The Vault that works where it matters least
Every service's local appsettings.json enables Convey's Vault integration — token auth, KV v2, even a PKI block requesting a per-service certificate for *.pacco.io. Every service's appsettings.docker.json disables all of it. The secrets manager is live exactly where secrets are developer-local and disposable, and off in the environment that resembles deployment. That inversion would be merely funny if the umbrella repo didn't also contain the counter-evidence: docker-images.txt, the estate's run-book, walks through a genuinely production-shaped Vault build — vault operator init, unseal ceremony, userpass auth with a scoped services policy, KV upgraded to v2, a database secrets engine issuing dynamic per-service MongoDB credentials on a one-hour TTL, and a PKI engine cutting per-service certificates. Somebody on this estate knew precisely what good looked like, wrote it down beautifully, and wired none of it into the thing that runs. For a governance lens this is the most instructive artefact in the whole system: the documentation is the theatre when the runtime doesn't implement it — an auditor reading the run-book and an operator reading the compose files would describe two different estates.
The scrub-list that scrubs nothing it needs to
Finally, the observability seam. Every service's logger config carries an excludeProperties list — Email, Password, Token, ConnectionString and friends — the recognisable shape of a PII-masking control. And then IdentityService logs like this:
_logger.LogError($"Invalid email: {command.Email}");
Interpolated strings. By the time Serilog sees the event, there is no Email property to exclude — the address has already been baked into the message text. The scrub-list can only mask structured properties, so the control is real, correctly configured, and unreachable from the code as written; every invalid sign-in and duplicate registration writes an email address into the console, the rolling file and Seq. One floor up, the gateway does the same thing at estate scale: customErrors.includeExceptionMessage: true in ntrada.yml forwards raw exception text through the edge to any caller. Neither is a leak of secrets; both are the same lesson — a masking control is a contract with your logging call-sites, and interpolation breaks the contract silently. (Credit where due: the HTTP client's requestMasking is on, and URLs do get masked. The estate is not careless; it is inconsistent, which in operations is the harder disease to catch.)
The ledger, and the fair paragraph
Six findings, one shape: a control named, a control hollow. Self-service admin behind an admin-gated route table; an ownership check that exempts the anonymous; verification theatre over shared-key issuance; a Vault enabled where it is pointless; a scrub-list bypassed by string interpolation; a run-book that documents the security the runtime declines to run.
To be fair to DevMentors: every one of these is a defensible demo economy. Self-service admin means a course attendee can bootstrap the walkthrough without seeding a database; dev-mode Vault and guest brokers mean docker-compose up works on a laptop in one command; the committed key means thirteen repos verify each other's tokens with zero coordination. Pacco never claims to be hardened, and the MIT licence is not a compliance statement. The reason to write these findings down is not to grade a sample against a standard it never sat; it is that each of these shortcuts is invisible at copy time — nothing fails, nothing warns — and each has a real-world descendant I have met in systems that started life as a cloned sample. The governance discipline is a boring checklist applied at adoption: who can assign roles, where does the signing key live, does the secrets manager run where deployment runs, do ownership checks fail closed, can a log call-site defeat the masking config. Five questions, one afternoon, and the theatre becomes reality — or at least becomes honest.
The service that mints all of this identity deserves its own close read, because at fifteen files it is the estate's smallest — and mostly its best — and its two real bugs are both instructive: identity in fifteen files.