Vaulthalla Logo

Scaling RBAC: When Permissions Aren’t Enough

Published

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.