Scaling RBAC: When Permissions Aren’t Enough
When RBAC Breaks, It Breaks Everything
You don’t understand an access control system when it’s working.
You understand it when it starts denying the wrong things — or worse, allowing them.
RBAC systems look clean — right up until you need them to actually enforce something.
Roles. Permissions. Bitmasks.
On paper, it’s trivial.
In practice, it’s one of the fastest ways to discover your system doesn’t actually understand itself.
TL;DR
- RBAC doesn’t fail at definition — it fails at resolution
- Static permission systems break down under:
- filesystem traversal
- path-based overrides
- multi-scope role models
The solution isn’t “more permissions.”
It’s building a system that can resolve them correctly under pressure.
Vaulthalla ended up with:
- composable permission trees (template-driven)
- trait-based resolution layers
- a deterministic policy evaluation engine
All of that exists to answer one question:
“What is the correct decision right now?”
It took six days of breaking everything to get there.
Six days without a clean compile.
At some point, you stop fixing bugs and start questioning whether the system deserves to exist in its current form.
That’s usually when you’ve finally met the beast.
I hit that wall hard with Vaulthalla.
The system compiled. The tests passed.
But I didn’t trust it.
Not with real data.
Not with real users.
Not with a filesystem sitting behind it.
I’d already rebuilt RBAC twice.
How can it possibly be this difficult?
That question came up a lot.
So I did what any sane engineer does when the foundation feels wrong:
I broke everything. On purpose.
This Wasn’t a Refactor
This wasn’t renaming types or reorganizing files.
This was a teardown.
Six days of ripping through everything touched in the sprint under one assumption:
It’s going to break 50 more times anyway. Don’t patch it — understand it.
No clean compile.
No steady progress.
Just one realization:
The model itself was incomplete.
The Real Problem Wasn’t Permissions
It was resolution
RBAC answers:
“What is allowed?”
Real systems need:
“Should this be allowed right now, in this exact context?”
That distinction doesn’t matter in small systems.
It matters a lot when you introduce:
- filesystem traversal
- path-based overrides
- domain boundaries (admin vs vault)
- real user behavior
The question stops being:
“Does this role have permission?”
And becomes:
“What is the correct decision at this exact point in the system?”
Scaling RBAC
Early Vaulthalla used two 16-bit masks.
Simple. Fast. Clean.
Also:
completely insufficient
The cracks showed quickly.
How do you scale RBAC to be modular, expressive, and composable?
The answer ended up being:
- 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> {}
Composable → type-safe → compact
But this introduces a new problem:
How do you resolve these structures at runtime?
The Chain That Emerged
At some point during the teardown, a pattern surfaced:
qualified name → permission resolution → override correctness → filesystem behavior
That chain wasn’t designed.
It revealed itself.
And once it did, everything else snapped into place.
The Realization
RBAC isn’t about defining permissions.
It’s about building a system that can:
resolve them correctly under pressure
Resolvers
Vaulthalla permissions are scoped.
Example:
1admin.vaults2├── self3├── admin4└── user
This demanded abstraction.
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
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
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, we can answer simple permission checks cleanly.
Permission Resolution (The Missing Layer)
Defining permissions wasn’t enough.
We needed to map:
1"admin.roles.admin.assign"
→ actual permission targets
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
Turn strings into structure
1if (parts[2] == "admin") return &role.roles.admin;2if (parts[2] == "vault") return &role.roles.vault;
PermissionResolver
Bridges CLI / 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:
Translate user intent into a valid permission mutation
The Final Layer: Overrides
Static permissions aren’t enough.
You need:
context-aware overrides
Scoped. Path-based. Dynamic.
This is where most RBAC systems fail.
policy::Evaluator
This 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}
Deterministic. Layered. Final.
What This Actually Solves
We replaced:
“Does this user have permission?”
With:
“Given everything — roles, groups, overrides, path, and action — 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");
All that complexity collapses into:
one correct boolean
Recap
RBAC is not about enforcing permissions.
It is about:
resolving them correctly under pressure
Vaulthalla’s RBAC system went from ~6 files to ~80.
But the complexity moved where it belongs:
inside the system — not in the calling code
Final Thought
There’s a difference between:
- a permission system
- and a decision engine
A sword may protect a kingdom.
A vault may protect a legacy.
Vaulthalla’s RBAC system does both.
