Case study

Dynamic RBAC for 325+ Endpoints in ASP.NET Core

Every endpoint was already written. The requirement changed to roles that could be invented at runtime, with permissions granted per module, per feature and per action. Without restructuring any of them.

ASP.NET CoreAuthorizationEF CoreCaching
Role
Backend developer
Where
Vertex Special Technologies
When
2025
01

Where it started

The project had been in development for about a year on the simplest thing that works: endpoints marked with the framework's built-in authorize attribute, and a single table mapping a role name to a menu name to a string of allowed actions. Three text columns. No foreign keys, no validation, nothing stopping a typo from silently granting or denying access.

That is a reasonable place to start and a bad place to stay. By the time the requirement changed, 325+ endpoints had already been written. Nothing was live yet, which removed the pressure of migrating real customer data and left a different problem: a surface too large to revisit by hand.

The ask: let an administrator create a role at runtime and grant it permissions at the level of a module, the features inside it, and the individual actions on each feature. No deploy, no code change, no developer.

02

What made it hard

325 endpoints already existed

Whatever replaced the old scheme had to attach to endpoints that were already written and reviewed. A design requiring each one to be restructured was not a design, it was a rewrite of the API surface.

512 possible permissions

Eight actions across sixty-four resources. ASP.NET Core expects authorization policies to be registered at startup, and registering five hundred of them by hand is a maintenance problem that grows every time someone adds a resource.

Checked on every request

A permission check runs before every protected action. Resolving it against the database each time turns authorization into a query on the hot path of the entire API.

Modules had to be switchable

Features are grouped into modules and sub-modules, and a module can be inactive. An inactive module's permissions must not be grantable, which makes module state part of the authorization model rather than a UI concern.

03

The shape of the solution

The endpoint declares what it needs in terms the business uses, an action and a resource. Everything else is resolved at runtime.

// Illustrative. Not the real implementation.
[HasPermission(Action.Update, Resource.Employee)]
public async Task<IActionResult> UpdateEmployee(...)

That attribute does one thing: it turns the pair into a policy name, and hands it to the framework as an ordinary authorization policy. The rest of the design exists to make that name resolvable without anyone registering it.

  1. 1The attribute composes a policy name from the action and resource, in the shape Permissions.Resource.Action.
  2. 2A custom policy provider intercepts any policy name in that shape and builds the policy on demand. Nothing is registered at startup, so adding a resource costs one constant and no configuration.
  3. 3An authorization handler receives the requirement, reads the caller's role, and decides.
  4. 4The role's entire permission set is loaded once and held as a set in memory. The check itself is a set lookup, not a query.

The dynamic policy provider is the piece that makes the whole thing viable. Without it the design collapses back into registering every combination at startup, which is exactly the manual maintenance the client was trying to escape.

04

The permission model

A permission is not a string. It is a row.

  • Actions and features are separate tables. A feature-action is the join between them, and that join row is the atomic unit of permission.
  • A role's grant is a foreign key to a feature-action, stored as an extension of the framework's own role-claim table rather than as a replacement for it.
  • Features are placed inside a module and a sub-module, and a module carries an active flag.

The obvious alternative was to keep the framework's role claims as they are and write the permission string into the claim value. That is less code and it is what most tutorials show.

I went with the extra tables because it felt cleaner, which is the honest reason. Coming off a scheme whose whole problem was untyped strings, another string in another column was not appealing.

What that instinct turned out to buy: a permission that does not exist cannot be granted, because the database will not accept the row. Renaming a resource became a migration rather than a search across string literals and seed data. And the permission catalogue can be queried, which is what the administration screen needs in order to render the grid of modules, features and actions that an administrator ticks. That last one was not part of the reasoning at the time; it just made the screen possible to build.

05

The tradeoff: a cache on the authorization path

A permission check that hits the database is correct and slow. A cached one is fast and can be wrong. The interesting question is how wrong, and for how long.

Resolving a role's permissions requires a join across role claims, feature-actions, features and actions. Doing that on every request to a protected endpoint would put a query in front of essentially the whole API.

So the resolved set is cached per role, and the check becomes a lookup in a hash set. The cost is staleness, and staleness in an authorization system is not a cosmetic problem: it means someone keeps access they were supposed to lose.

Two things bound it. Changing a role's permissions evicts that role's entry as part of the same transaction that writes the change, and the entry also expires on its own after a day. The order matters: the invalidation is the mechanism and the expiry is only a backstop, for the case where something evicts nothing because it never knew a write happened. A day is a long time to be wrong about access, which is exactly why it is not the thing being relied on.

Assignment is a replace, not a merge

Granting permissions for a module removes the role's existing grants within that module and writes the new set, inside a transaction. Sending the same request twice produces the same state, and a partial failure leaves the role exactly as it was. It also means the administration screen can send what it sees rather than compute a diff.

06

What it adds up to

325+
endpoints covered
512
possible permissions
0
queries per check

Adding a resource is one constant and a seed row. Adding an endpoint is one attribute. Neither requires a developer to touch authorization configuration, and neither requires a restart to take effect for the people using it.

07

What I would do differently

The cache is per process

An in-process cache is invalidated in the process that handled the write. Run a second instance behind a load balancer and that instance keeps serving the old permission set until its own entry expires. For a single-instance deployment this is correct and cheap; the moment it scales out it needs a shared cache or a broadcast, and that is a decision better made before the second instance exists than after.

Start with the model, not the attribute

The string-based table lasted a year and cost more to leave than it saved to write. The permission model was the part that needed thinking about; the plumbing that reads it took far less time than the migration away from what came before.