
Scaling RBAC: When Permissions Aren’t Enough
RBAC rarely fails at definition. It fails at resolution. How Vaulthalla built a C++ decision engine for encrypted vaults, group roles, and path overrides.
When RBAC Breaks, It Breaks Everything
You don’t really understand an access control system while it’s working. You understand it when it starts denying the wrong things, or worse, allowing them.
On paper, RBAC is simple: roles, permissions, bitmasks. In practice, it’s one of the fastest ways to discover that your system doesn’t fully understand itself.
TL;DR
- RBAC rarely fails at definition. It fails at resolution.
- Static permission systems break down under:
- filesystem traversal
- path-based overrides
- multi-scope role models
Adding more permissions doesn’t fix that. What fixes it is a system that can resolve permissions correctly under pressure.
Vaulthalla ended up with:
- composable permission trees (template-driven)
- trait-based resolution layers
- a deterministic policy evaluation engine
All of it exists to answer one question: what is the correct decision right now?
Getting there took six days of breaking everything.
Six days without a clean compile.
At some point you stop fixing bugs and start asking whether the system deserves to exist in its current form. That’s usually the moment you’ve found the real problem.
I hit that wall hard with Vaulthalla.
The system compiled and the tests passed, but I didn’t trust it with real data, real users, or a real filesystem behind it.
I had already rebuilt RBAC twice, and I kept asking myself how it could possibly be this difficult. So I did what you do when the foundation feels wrong: I broke everything on purpose.
This Wasn’t a Refactor
I wasn’t renaming types or reorganizing files. This was a teardown.
For six days I worked through everything the sprint had touched, under one assumption: it was going to break another fifty times anyway, so I should stop patching it and understand it instead.
There was no clean compile and no steady progress. What I came away with was one conclusion: the model itself was incomplete.
The Real Problem Wasn’t Permissions
The real problem was resolution.
RBAC answers the question “what is allowed?” Real systems need to answer a harder one: should this be allowed right now, in this exact context?
In a small system the difference barely matters. It matters a great deal once you introduce:
- filesystem traversal
- path-based overrides
- domain boundaries (admin vs vault)
- real user behavior
At that point the question is no longer whether a role has a permission. It’s what the correct decision is at this exact point in the system.
Scaling RBAC
Early Vaulthalla used two 16-bit masks. They were simple, fast, and clean, and they turned out to be completely insufficient. The cracks showed quickly.
The question became how to make RBAC modular, expressive, and composable. The answer was a combination of three things:
- templates for structure
- traits for metadata and resolution
- bitpacking for efficiency
The Tree Pattern
Permissions are modeled as composable building blocks:
1template<typename EnumT, typename MaskT>2struct Set {}
1template<typename MaskT>2struct Module {}
1template<typename MaskT, typename SetEnumT, typename SetMaskT>2struct ModuleSet : Module<MaskT> {}
The result is composable, type-safe, and compact. It also raises a new problem: how do you resolve these structures at runtime?
The Chain That Emerged
Partway through the teardown, a pattern surfaced:
qualified name → permission resolution → override correctness → filesystem behavior
I didn’t design that chain up front. It showed up once the pieces were in the right places, and after that the rest of the system fell into line quickly.
The Realization
RBAC is less about defining permissions than about building a system that can resolve them correctly under pressure.
Resolvers
Vaulthalla permissions are scoped. For example:
1admin.vaults2├── self3├── admin4└── user
That scoping called for an abstraction.
Resolver Traits
Resolver traits define where permissions live:
1template<>2struct AdminResolverTraits<permission::admin::keys::APIPermissions> {3 static constexpr auto domain = Domain::APIKey;4 5 static const auto& direct(const role::Admin& role) { return role.keys; }6 static const auto& self(const decltype(std::declval<identities::User>().apiKeysPerms().self)& perms) { return perms; }7 static const auto& admin(const decltype(std::declval<identities::User>().apiKeysPerms().admin)& perms) { return perms; }8 static const auto& user(const decltype(std::declval<identities::User>().apiKeysPerms().user)& perms) { return perms; }9};
Context Policy
A context policy defines how permissions are evaluated:
1template<typename EnumT>2struct ContextPolicy {3 static bool validate(const std::shared_ptr<identities::User>&,4 const ResolvedContext&,5 EnumT,6 const Context<EnumT>&) {7 return true;8 }9};
resolver::Admin
resolver::Admin is the first clean “can this be done?” layer:
1class Admin {2public:3 template<typename EnumT>4 static bool has(admin::Context<EnumT>&& ctx) {5 if (!ctx.isValid()) return false;6 7 if (ctx.user->isSuperAdmin()) return true;8 9 const auto resolved = resolveContext(ctx);10 if (!resolved.isValid()) return false;11 12 return checkPermissions(ctx.user, resolved, ctx);13 }14};
At this layer, simple permission checks are answered cleanly.
Permission Resolution (The Missing Layer)
Defining permissions wasn’t enough. We also needed to map a qualified name like this one to the actual permission it targets:
1"admin.roles.admin.assign"
Target Traits
Target traits define where a permission applies:
1template<>2struct PermissionTargetTraits<permission::vault::fs::FilePermissions> {3 static constexpr auto domain = RoleDomain::Vault;4 static constexpr bool canOverride = true;5 6 static auto& target(role::Vault& role) { return role.fs.files; }7 static const auto& target(const role::Vault& role) { return role.fs.files; }8};
Qualified Name Parsing
Parsing turns the string into structure:
1if (parts[2] == "admin") return &role.roles.admin;2if (parts[2] == "vault") return &role.roles.vault;
PermissionResolver
PermissionResolver bridges the CLI and API into RBAC:
1template<typename RoleT, typename... Enums>2class PermissionResolver {3public:4 static bool apply(Role &role, const PermissionLookup &lookup);5 static bool applyOverride(...);6 static bool has(const Role &role, const permission::Permission &perm);7};
All of this exists for one reason: to translate user intent into a valid permission mutation.
The Final Layer: Overrides
Static permissions only get you so far. A real system needs context-aware overrides that are scoped, path-based, and dynamic.
This is where most RBAC systems fall over.
policy::Evaluator
policy::Evaluator is the decision engine.
Request
1struct Request {2 std::shared_ptr<identities::User> user;3 permission::vault::FilesystemAction action{};4 std::optional<uint32_t> vaultId{};5 std::optional<std::filesystem::path> path{};6};
Decision
1struct Decision {2 bool allowed{false};3 Reason reason;4 std::optional<std::string> evaluated_path;5 std::optional<std::string> matched_override;6};
Core Flow
1Decision Evaluator::evaluate(const Request &req) {2 // 1. Direct vault role3 // 2. Group roles4 // 3. Global policies5 // 4. Implicit deny6}
Evaluation is deterministic and layered, and it always ends in a final answer.
What This Actually Solves
The old question was “does this user have permission?” The new one is: given the roles, groups, overrides, path, and action involved, what is the correct decision?
What This Looks Like in Practice
1if (!resolver::Vault::has<permission::vault::RolePermissions>({2 .user = call.user,3 .permission = permission::vault::RolePermissions::AssignOverride,4 .target_subject_type = subj.type,5 .target_subject_id = subj.id,6 .vault_id = vault->id7})) return invalid("You do not have permission to assign overrides");
From the caller’s side, all of that machinery comes down to one correct boolean.
Recap
RBAC is about resolving permissions correctly under pressure, not just enforcing them.
Vaulthalla’s RBAC system grew from about 6 files to about 80. The complexity didn’t disappear, but it now lives inside the system instead of in the calling code, which is where it belongs.
Final Thought
A permission system tells you what has been granted. A decision engine tells you what should happen. Vaulthalla needed the second one.
A sword may protect a kingdom.
A vault may protect a legacy.
Vaulthalla’s RBAC system does both.
Keep reading
Why Vaulthalla Requires a WebSocket Session Before HTTP
A stolen token shouldn’t be a skeleton key. Vaulthalla only honors HTTP requests backed by a live WebSocket session, so access ends when the connection does.
Read articleThe Best Ideas Don’t Happen While You’re Coding
Lessons from building Vaulthalla, a self-hosted encrypted cloud in C++: the pause while the compiler runs is where bad abstractions finally stand out.
Read article