Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/register-as-the-admin-user && read --section="top" 0%
Architecture

Register as the Admin User

Inflow's request collection contains a captioned request that grants full administrative permissions to an anonymous caller. The mechanism is four lines long, the documented happy path requires it, and the repository nowhere says it is on purpose - here is all of that, and the case for the defence.

By Kumar Chandrachooda 07 Feb 2026 6 min read
An open door and an arrow that skips two tiers to reach the crown

Part 3 left the other four root requests failing for want of a credential. Getting one is easy. Two thirds of the way down Users.rest, between changing your password and browsing the user list, sits this:

### Register as the admin user
POST {{url}}/account/sign-up
Content-Type: application/json

{
  "email": "{{adminEmail}}",
  "password": "{{password}}",
  "role": "admin"
}

That request is anonymous, it succeeds, and the account it creates holds every permission the application defines. This article states the mechanism precisely, states the case for the defence with equal weight, and leaves the judgement where it belongs.

The mechanism, in four hops

Hop one. AccountController.SignUpAsync carries no [Authorize]:

[HttpPost("sign-up")]
[SwaggerOperation("Sign up")]
public async Task<ActionResult> SignUpAsync(SignUp command)
{
    await _dispatcher.SendAsync(command);
    return NoContent();
}

The command goes to the dispatcher unmodified. Nothing between the request body and the handler inspects, filters or rebinds the Role property. Contrast this with almost every other write endpoint in the estate, where the controller calls command.Bind(x => x.CustomerId, _context.Identity.Id) to overwrite a client-supplied identifier with the authenticated one. That defensive rebind is a house habit here. It is not applied to Role.

Hop two. SignUpHandler reads the role straight off the body:

var roleName = string.IsNullOrWhiteSpace(command.Role) ? Role.Default : command.Role.ToLowerInvariant();
var role = await _roleRepository.GetAsync(roleName)
    .NotNull(() => new RoleNotFoundException(roleName));

The only validation is that the role exists. There is no allow-list of self-assignable roles, no check on the caller's identity — there is no caller identity, the endpoint is anonymous — and no ceiling on what a role may carry. The default when the field is absent is Role.Default, which is user, so the omission case is safe; the supplied case is not checked at all.

Hop three. UsersInitializer runs once on first boot and seeds exactly two roles:

private readonly HashSet<string> _permissions = new()
{
    "customers",
    "deposits", "withdrawals",
    "users",
    "transfers", "wallets"
};

admin gets that set; user gets new List<string>(). Those six strings are exactly and completely the union of the Policies declared by all five modules — customers from Customers, deposits and withdrawals from Payments, users from Users, transfers and wallets from Wallets. The admin role is defined as “everything”, by construction, and stays that way as modules are added.

Hop four. The permissions reach the token and become policy checks. SignInHandler:

var claims = new Dictionary<string, IEnumerable<string>>
{
    ["permissions"] = user.Role.Permissions
};

var jwt = _authManager.CreateToken(user.Id, user.Role.Name, claims: claims);

AuthManager.CreateToken expands that dictionary into one Claim("permissions", value) per entry. And the host registers every module policy the same way:

authorization.AddPolicy(policy, x => x.RequireClaim("permissions", policy));

Four hops, no gaps. An anonymous POST names a role; the role carries six permission strings; the strings become claims; the claims satisfy every policy in the application.

It is required, not merely permitted

The sharper fact is not that the escalation is possible. It is that the estate's own demonstration cannot be completed without it.

PUT /customers/{customerId}/verify carries [Authorize("customers")]. That endpoint is the sole publisher of CustomerVerified, which is the sole trigger for the DepositAccount, which is the sole trigger for the Wallet. As part 2 traced, there is no other path to a wallet in this application. Separately, POST /transfers/incoming and POST /transfers/outgoing both carry [Authorize("transfers")].

The seeded user role holds zero permissions. So the only account you can create without escalating satisfies no policy in the estate, and step 3 of the fourteen-step path is unreachable from it. The only way to obtain a role that can verify a customer is to ask for it in an anonymous request body, and Users.rest shows you how, with a caption.

The case for the defence, at equal weight

Everything above is true, and stopping there would be unfair, so here is the rest of the record.

This is a free, MIT-licensed teaching repository. It has no deployment, no hosted instance, no Dockerfile on any branch, and no CI. Its run-book binds it to http://localhost:5000 and its documented database has an empty password paired with POSTGRES_HOST_AUTH_METHOD=trust. Nothing here was ever exposed to anyone.

The author did think about registration. RegistrationOptions exists as a first-class module setting, and SignUpHandler opens by consulting it:

if (!_registrationOptions.Enabled)
{
    throw new SignUpDisabledException();
}

module.users.json sets registration.enabled: true, alongside a list of disposable-email providers to reject. The kill switch was built, and it was shipped on, because a sample application nobody can register against is useless. That is a defensible product decision, not an oversight.

The default is also closed rather than open. Omit role and you get user, which grants nothing. The escalation is opt-in, explicit, and requires the caller to type the word admin — it is not a default-permissive accident where the first account silently becomes a superuser.

And the obvious alternative is worse. Seeding a fixed admin@inflow.io account with a published password would put a real credential in a public repository, which is a different failure and arguably a bigger one. The author's .gitignore already reserves appsettings.local.json and module.*.local.json for local overrides, so he had a mechanism for keeping secrets out; seeding a shipped admin password would have walked straight past it.

Set against that: this application models virtual payments, the escalation is not a side door but the main corridor, and a reader who lifts SignUpHandler into something deployed inherits an anonymous role-assignment endpoint that reads, in source, exactly like an ordinary registration handler. Nothing marks it.

What the repository does not say

That last point is the finding I would actually file, and it is narrow and checkable.

  • The README does not mention it. It describes Users as “managing the users/identity (register, login, permissions etc.)” and says nothing about self-assignable roles.
  • SignUpHandler has no comment. The estate's one intent comment about a simplification is elsewhere, in CompleteDepositHandler, and it is a good one — part 10 is about the gap between those two files.
  • There is no ADR, no decision log, no docs/ folder. README.md is the only Markdown file in the repository on every branch.
  • No test covers it, because nothing tests the Users module at all.
  • The .rest caption states what to do. It does not state that it is a shortcut.

The narrow assertion is this: the mechanism is as described, the documented path requires it, and the repository nowhere records it as deliberate. Whether that matters is the reader's call, and it depends entirely on what the reader does next. Run it on a laptop to learn modular monoliths and it costs nothing. Copy SignUpHandler into a service that will one day face the internet and it costs everything.

The rule I would carry out of it

There is a version of this that is not about Inflow at all, and it is worth stating flatly because it generalises past sample code:

An anonymous endpoint that accepts a privilege name from the request body must validate it against an allow-list, not against existence. GetAsync(roleName) answers "is this a role?". The question you need answered is “is this a role an unauthenticated stranger may grant themselves?” — and the two questions look identical in the source, one line apart, which is exactly why this pattern survives review.

Inflow already has the shape of the fix everywhere else in its own codebase. Every other write endpoint rebinds the client's identifier from the authenticated context before the command leaves the controller. Applying the same instinct here is one line: bind Role to Role.Default on the anonymous path, and let an authorised endpoint promote afterwards.

That is four articles of things the repository gets wrong, and the ledger would be dishonest if it stopped there. Next, eleven claims, and most of them true — the README audited sentence by sentence, including the strongest claim in it, which holds up better than anything else in this series.