Skip to main content

Command Palette

Search for a command to run...

I Built an RBAC System Called Osiki—Here's What 5 Years of Engineering Taught Me

Updated
7 min read

There's a security guard at most gates in Ghana. We call him "Osiki." He knows who lives in the compound, who's visiting, and who shouldn't be there. He doesn't need a manual—he just knows.

That's the kind of access control I wanted to build. Something intuitive, flexible, and battle-tested. So I built Osiki—an open-source Role-Based Access Control system born from years of painful lessons in production systems.


The Systems That Broke Me (In a Good Way)

Over the past five years, I've built software that needed to answer one deceptively simple question: "Can this user do this thing?"

It sounds easy until you're staring at:

A centralized hospital information management system where doctors need access to patient records, but only in their department. Nurses need different access. Lab technicians need another slice. And the hospital administrator? They need to see everything—but shouldn't be able to modify clinical data. Now multiply that by three shifts, locum doctors, and students on rotation.

An internal tooling platform for a fintech operating across three countries—Ghana, Uganda, and Zambia—with a branch in each capital. A country manager in Accra shouldn't see Ugandan customer data. But a regional fraud analyst needs to see patterns across all three markets. And what about when someone transfers from one country to another? Their permissions need to follow—but only for certain resources.

Multi-tenant systems where one codebase serves completely different organizations, each with their own hierarchy, their own rules, and their own definition of "admin."

Every time, we'd cobble together something that worked. Hardcoded role checks scattered across the codebase. Permission logic buried in if-statements. Audit trails that were more like audit suggestions.

I got tired of rebuilding the same thing badly. So I decided to build it once, properly.


What Osiki Actually Does

At its core, Osiki answers, "Does this user have permission to perform this action on this resource in this context?"

But the devil's in the details.

The Permission Model

Permissions in Osiki are atomic pairs: resource:action.

invoices:read
invoices:write
users:delete
reports:export

Simple. Composable. And wildcards work exactly how you'd expect:

  • invoices:* — all actions on invoices

  • *:read — read access to everything

  • *:* — god mode (use sparingly)

This pattern comes directly from AWS IAM. If you've written IAM policies, you'll feel right at home.

Role Hierarchies

Roles can inherit from other roles. If admin inherits from editor, and editor inherits from viewer, then assigning someone admin automatically grants them everything below.

admin
  └── editor
        └── viewer

No need to manually assign three roles. The hierarchy handles it.

Scoping (This Is Where It Gets Interesting)

Here's where Osiki diverges from simpler RBAC systems. Every role assignment is scoped.

# Simple single-tenant
scope = {"tenant_id": "acme_corp"}

# Multi-level
scope = {
    "tenant_id": "acme_corp",
    "country": "ghana",
    "department": "finance"
}

# Global access (superadmin)
scope = {}  # Empty means everywhere

The matching rule is intuitive: your scope must be a subset of (or equal to) the requested scope.

If you have {tenant_id: "acme_corp"}, you can access {tenant_id: "acme_corp", country: "ghana"}. You're scoped to the entire organization, so Ghana is included.

But if you only have {tenant_id: "acme_corp", country: "ghana"}, you can't touch {country: "uganda"}. Your access is narrower than what you're requesting.

This single rule handles multi-tenancy, regional access, and departmental isolation—all without special cases.

Groups

Because assigning roles to 500 users individually is nobody's idea of fun.

Create a group. Assign roles to the group. Add users to the group. Done.

Constraints

Real organizations have rules that go beyond "who can do what":

  • Mutual exclusion: A user can't be both payment_approver and payment_requester (separation of duties)

  • Cardinality limits: Only 3 users can have the superadmin role

  • Prerequisites: You must have basic_user before you can be assigned manager

These aren't afterthoughts in Osiki—they're first-class citizens.

Audit Trail

Every single thing is logged. Role assignments. Revocations. Permission checks (both allowed and denied). Who did what, when, and in what context?

Because someday, someone will ask, "Who had access to the payroll system on March 15th?" And you'll have an answer.


The Technical Decisions (And Why I Made Them)

MongoDB + Beanie ODM

I know what you're thinking. "RBAC is inherently relational. Why MongoDB?"

Two reasons:

Cost. MongoDB Atlas has a genuinely free tier (512MB) that stays free. I wanted Osiki to be something anyone could spin up without a credit card. PostgreSQL options have more gotchas for hobby projects.

Familiarity. I've spent years with MongoDB. When you're learning new concepts (and RBAC has plenty of edge cases), fighting your database adds friction you don't need.

The trade-off is real, though. RBAC is many-to-many relationships all the way down. Users have roles. Roles have permissions. Users belong to groups. Groups have roles. It's joins everywhere.

We adapted by:

  • Using explicit junction collections (user_roles, group_roles) instead of embedding

  • Handling referential integrity in application code

  • Leaning heavily on Redis for computed results

Redis (For Caching, Not Sessions)

Computing a user's effective permissions is expensive. You need to:

  1. Get their direct role assignments

  2. Get their group memberships

  3. Get roles assigned to those groups

  4. Walk up the role hierarchy for each role

  5. Collect all permissions

  6. Check for wildcards

That's a lot of queries. So we cache aggressively.

Cache KeyWhat It StoresTTL
rbac:user:{id}:permissionsAll effective permissions5 min
rbac:role:{id}:ancestorsRole hierarchy chain1 hour

Five minutes feels right for permissions. Long enough to matter, short enough that revoked access doesn't linger. The hierarchy rarely changes, so an hour is fine there.

Any mutation (role assignment, permission change, etc.) invalidates the relevant cache keys immediately.

FastAPI

Async by default. Pydantic validation is built in. Automatic OpenAPI docs. It's become my default for Python APIs, and Osiki was no exception.


The Permission Resolution Algorithm

When you call can_user_do("user_123", "invoices:write", scope={"tenant_id": "acme_corp"}), here's what happens:

1. Check cache for user's computed permissions (with scope hash)
   → Cache hit? Return immediately.

2. Cache miss. Start computing:
   a. Get user's direct role assignments (filtered by scope)
   b. Get user's group memberships
   c. Get roles assigned to those groups (filtered by scope)
   d. Combine all role IDs

3. For each role, walk up the hierarchy:
   visited = set()  # Cycle detection
   while current_role:
       if current_role in visited:
           raise CycleDetectedError()  # Someone made a loop
       visited.add(current_role)
       ancestors.append(current_role)
       current_role = get_parent(current_role)

4. Collect all permission IDs from all roles (direct + inherited)

5. Resolve permission IDs to resource:action strings

6. Cache the result

7. Check: does the requested permission match?
   - Exact match: "invoices:write" == "invoices:write" ✓
   - Wildcard: "invoices:*" matches "invoices:write" ✓
   - Super wildcard: "*:*" matches everything ✓

The cycle detection isn't paranoia. I've seen production systems where someone accidentally created admin → manager → admin. Without detection, that's an infinite loop.

No Denies (For Now)

Osiki permissions are purely additive. If you have invoices:read from one role and invoices:write from another, you get both. There's no "deny" that overrides.

This is a deliberate simplification. AWS IAM has explicit denies, and they're powerful—but they also create debugging nightmares. For most applications, additive permissions are enough. Deny support might come later as an opt-in feature.


What's Next

Osiki is almost ready for public release. The core is solid. The tests are green. The documentation is... in progress.

A few things on the roadmap:

  • ABAC (Attribute-Based Access Control): Sometimes you need rules like "users can only edit documents they created" or "access is only allowed during business hours." That's beyond pure RBAC.

  • Resource-level permissions: Right now, you can control access to types of resources (invoices). Eventually, you should be able to control access to specific resources (invoice_123).

  • SDKs: Python first, then Node.js and Go.


Why Open Source?

Because I've benefited enormously from open source, and this is my way of giving back.

But also because access control shouldn't be a mystery. Too many systems have permissions scattered across the codebase, understood by one person who left the company two years ago. A well-designed RBAC system, with clear concepts and good documentation, makes the whole application easier to reason about.

If Osiki helps even one team avoid the pain I went through, it's worth it.


Try It Out

The repo will be public soon at https://github.com/bensonOSei/osiki Apache 2.0 license. Contributions are welcome.

If you've built RBAC systems before and have war stories, I'd love to hear them. If you're about to build one and have questions, reach out.

And if you're a security guard in Ghana reading this—you're the real MVP. 🫡


Benson is a senior backend engineer who's spent too much time thinking about permissions. He builds fintech systems across Africa and occasionally writes about the lessons learned along the way.

More from this blog

T

Tech by Benson: Web Development, PHP, Laravel, React, JavaScript Tips & Insights

7 posts

Benson, a seasoned programmer and tech enthusiast, shares expertise in web development, PHP, Laravel, React, and JavaScript. Join the journey of learning and growth in the ever-evolving tech world.