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

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`.

```plaintext
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.

```plaintext
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*.

```python
# 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 Key | What It Stores | TTL |
| --- | --- | --- |
| `rbac:user:{id}:permissions` | All effective permissions | 5 min |
| `rbac:role:{id}:ancestors` | Role hierarchy chain | 1 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:

```plaintext
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.*
