DEV Community

Cover image for Scaling Access Control: Leveling Up Roles with Relationships Using ReBAC
Aydrian
Aydrian

Posted on

Scaling Access Control: Leveling Up Roles with Relationships Using ReBAC

If you’ve ever built a collaborative app, you’ve probably reached for role-based access control (RBAC) right out of the gate—even if you didn’t call it that at the time. Maybe you just added a quick “is_admin” flag to your user model, or wrote a few if-statements to check if someone is a “member” before letting them do something. That’s RBAC in action, whether you realize it or not. It’s the default move: give users roles like “admin,” “member,” or “guest,” and lock down your endpoints accordingly. It’s simple, familiar, and gets the job done, at least at first.

Imagine you’re creating a shared travel app. In this case, it’s just the API, designed to help groups manage shared expenses on trips. Think splitting hotel bills, tracking who paid for gas, and making sure everyone settles up at the end. Like most projects, it starts with a straightforward RBAC setup. Users get roles, roles get permissions, and everything seems to work fine.

As the app grows, the requirements start to change. Suddenly, there’s a new challenge: what if you only want users to share expenses they actually created? RBAC doesn’t really have a good answer for that. Roles can’t capture the nuance of “this user owns that expense.” Now you need to be able to model a relationship between a user and an expense — and that’s where things start to get interesting.

The journey from RBAC to relationship-based access control (ReBAC) shows how real-world problems push access control beyond simple roles. Here’s how the shared-travel-app API used Oso to level up its authorization approach to meet those needs.

The Starting Point: Homemade RBAC

When you’re building out the first version of an API, you usually don’t overthink access control. Most developers just want to ship something that works.

That means reaching for the basics: you define a few roles, store them in your database, and sprinkle permission checks throughout your routes or controllers. There’s no fancy framework — just simple if-statements and a focus on getting the job done.

For the shared-travel-app API, the goal is to help groups manage shared expenses on trips. Users can create trips, add expenses, and invite others to join. To keep things organized, the app starts with three roles:

Role What They Can Do
Organizer
  • Full control of the trip (edit/delete)
  • Add, remove, or change participant roles
  • Create and manage all expenses
  • View everything (trip details, participants, expenses)
Participant
  • View trip details
  • See who else is on the trip
  • Create new expenses
  • Edit/delete their own expenses
  • View all expenses
Viewer
  • View trip details
  • See who is on the trip
  • View all expenses
  • Cannot create or modify anything

To keep things simple, I stored each user’s role for a trip in a trip_roles table:

CREATE TABLE `trip_roles` (
    `id` text PRIMARY KEY NOT NULL,
    `trip_id` text NOT NULL,
    `user_id` text NOT NULL,
    `role_id` text NOT NULL,
    `created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
    FOREIGN KEY (`trip_id`) REFERENCES `trips`(`id`) ON UPDATE no action ON DELETE cascade,
    FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
    FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade
);
Enter fullscreen mode Exit fullscreen mode

Then, I wrote a custom Hono.js middleware to check permissions by querying the database before handling each request. Here’s a simplified version:

async function rbacMiddleware(c, next) {
  const userId = c.req.header("x-user-id");
  const tripId = c.req.param("tripId");
  // Query the user's role for this trip
  const { rows } = await db.query(
    "SELECT role FROM trip_roles WHERE user_id = $1 AND trip_id = $2",
    [userId, tripId]
  );
  const role = rows[0]?.role;

  // Basic permission check
  if (role === "organizer") {
    return next();
  }
  if (
    role === "participant" &&
    c.req.method === "POST" &&
    c.req.path === `/trips/${tripId}/expenses`
  ) {
    return next();
  }
  if (role === "viewer" && c.req.method === "GET") {
    return next();
  }

  return c.text("Forbidden", 403);
}
Enter fullscreen mode Exit fullscreen mode

I could then just drop this middleware into my routes and call it a day.

It worked fine for a while, but as the app grew, the permission logic started to get messy and hard to maintain. That’s when I started looking for something more robust.

Migrating to Oso: Streamlining RBAC

In preparation to support new features that required more advanced permission structures, I realized my homemade AuthZ solution, while working fine and handled neatly using Hono.js middleware, wasn’t going to cut it for the next stage. I also recognized that I would have to keep adding this permissions logic to my controllers, middleware, and other parts of the app, which would quickly become hard to read, debug, and maintain. I wanted to use an authorization system that could scale with my needs and make it easy to introduce relationship-based permissions down the line. So, before things got complicated, I decided to migrate my existing role checks to Oso, setting myself up for a smoother path to ReBAC and beyond.

Oso is authorization as a service, a policy engine that lets you centralize your authorization logic and keep your code clean. Instead of sprinkling permission checks everywhere, you define your rules in one place and let Oso handle the rest. With the official Oso Cloud Node.js SDK, integrating Oso into my app was straightforward.

The Dual Writes Pattern

One of the first things I had to tackle was keeping my existing roles in sync while I migrated. Enter the Dual Writes Pattern: this involves keeping Oso Cloud in sync with the database by following any changes with fact updates.

Here’s a simplified example of what that looked like:

// Assign the user the "Organizer" role when creating a trip
await this.db.insert(tripRoles).values({
  tripId: dbTrip.id,
  userId: userId,
  roleId: organizerRoleId,
});

await this.oso.insert([
  "has_role",
  { type: "User", id: userId },
  { type: "String", id: "organizer" },
  { type: "Trip", id: dbTrip.id },
]);
Enter fullscreen mode Exit fullscreen mode

Centralizing Permissions with Oso

With Oso in place, I could finally move my permission logic out of the controllers and into policy files. Instead of a bunch of if statements, I defined my rules declaratively using Polar:

actor User {}

# Everyone will be added to "default" organization
resource Organization {
  roles = ["member"];
  permissions = ["trip.create", "trip.list"];

  "trip.create" if "member";
  "trip.list" if "member";
}

resource Trip {
  roles = ["organizer", "participant", "viewer"];
  permissions = ["manage", "view", "expense.create", "expense.list", "participants.list", "participants.manage"];
  relations = {
    organization: Organization,
  };

  "view" if "viewer";
  "participants.list" if "viewer";
  "expense.list" if "viewer";

  "viewer" if "participant";
  "expense.create" if "participant";

  "participant" if "organizer";
  "manage" if "organizer";
  "participants.manage" if "organizer";
}

resource Expense {
  roles = ["editor", "viewer"];
  permissions = ["manage", "view"];

  relations = {
    trip: Trip
  };

  "editor" if "participant" on "trip";
  "viewer" if "viewer" on "trip";


  "view" if "viewer";

  "viewer" if "editor";

  "manage" if "editor";
}
Enter fullscreen mode Exit fullscreen mode

Now, checking permissions in my routes was as simple as:

const user = c.get("user");
const oso = getAuthz(c);
const resourceId = resourceIdGetter(c);

const authorizeParams: AuthorizeParams<T> = [
  { type: "User", id: user.id },
  action,
  { type: resource, id: resourceId },
] as const;

const authorized = await(oso.authorize as AuthorizeFunction)(
  ...authorizeParams
);
Enter fullscreen mode Exit fullscreen mode

I no longer needed to rely on if statements and database queries to verify permissions. Switching to Oso api calls simplified my custom authZ middleware.

The Benefits

Switching to Oso gave me a bunch of wins:

  • Centralized policies: All my authorization logic lives in one place, making it easy to audit and update.
  • Cleaner code: No more scattered permission checks, just simple calls to Oso.
  • Easier onboarding: New contributors can see all the rules at a glance, without hunting through controllers.
  • Flexibility: When requirements change, I update the policy, not a dozen different files.

Migrating to Oso allowed me to keep moving fast without sacrificing security or maintainability. And as you’ll soon discover, it made it a lot easier to handle more complex scenarios down the road.

When Roles Aren’t Enough: The Expense-Sharing Challenge

Things were smooth with RBAC until I hit a real-world scenario that didn’t fit the mold: what if I only want participants to be able to view or edit expenses they actually created? In other words, a participant shouldn’t be able to view or edit expenses created by someone else on the same trip — unless the expense has specifically been shared with them. And only the creator of an expense should be able to share it with others.

This is where traditional RBAC starts to fall apart. Roles are great for broad strokes — organizers can do everything, participants can add expenses, viewers can look but not touch. But roles alone don’t capture the relationships between users and specific resources. In this case, the permission isn’t just about being a participant; it’s about being the creator of a particular expense, or having that expense shared with you by its creator.

To handle this, I needed something more granular: relationship-based access control (ReBAC). With ReBAC, I can define permissions based on how a user is connected to a resource, not just what role they have. This lets me say, “You can only manage expenses you created, or those that have been shared with you,” which is exactly what the app needed as it grew more complex.

Introducing ReBAC: Relationship-Based Access Control

So what exactly is ReBAC, and how is it different from the RBAC most of us start with?

RBAC (Role-Based Access Control) is all about grouping permissions based on roles. The organizer role might include view, create, edit, and delete permissions. If you’re assigned the organizer role, you gain access to those permissions; if you’re assigned a participant role, you get a set of permissions defined by that role. It’s simple and works well for broad categories of users, but it doesn’t account for the relationships between users and specific resources.

ReBAC (Relationship-Based Access Control) takes things a step further. Instead of just looking at what role a user has, ReBAC considers how a user is connected to a resource. For example, you might have permission to edit an expense not just because you’re a participant, but because you’re the creator of that expense — or because the creator has shared it with you.

This model is a perfect fit for the expense-sharing challenge. With ReBAC, I can say, “Participants can view or edit expenses they created, and can share those expenses with others.” The permissions aren’t just tied to roles, but to the relationships users have with each expense.

Extending Oso: From RBAC to ReBAC

One of the best things about using Oso is how easy it is to evolve your authorization model as your app’s needs change. I started with classic RBAC policies, but when it was time to support more granular, relationship-based permissions, I didn’t have to rewrite everything. I just extended my existing policies.

Oso’s policy engine is designed for this kind of flexibility. Instead of locking you into a rigid structure, it lets you define new facts and rules as your requirements grow. For the expense-sharing feature, I needed to express relationships like “user is the creator of this expense” or “expense has been shared with these users.”

Adding New Facts

To support these new relationships, I started keeping track of which users created which expenses, and which expenses were shared with which users. This meant syncing a couple of new facts to Oso Cloud whenever data changed:

// Set up Oso relations in create expense
await this.oso.batch(async (tx) => {
  // Set trip relation
  await tx.insert([
    "has_relation",
    { type: "Expense", id: newExpense.id },
    "trip",
    { type: "Trip", id: tripId },
  ]);

  // Set owner relation
  await tx.insert([
    "has_role",
    { type: "User", id: userId },
    { type: "String", id: "owner" },
    { type: "Expense", id: newExpense.id },
  ]);
});
Enter fullscreen mode Exit fullscreen mode

Updating the Policy

With the new facts in place, updating my main.polar policy file was straightforward. I just added rules to check for these relationships:

resource Expense {
  roles = ["owner", "viewer"];
  permissions = ["manage", "view", "share"];
  relations = {
    trip: Trip,
    shared_with: User
  };

  # Owner permissions (expense creator)
  "owner" if "owner";
  "manage" if "owner";
  "view" if "owner";
  "share" if "owner";

  # Shared permissions
  "viewer" if "shared_with";
  "view" if "shared_with";

  # Trip organizers can manage any expense
  "manage" if "organizer" on "trip";
  "view" if "organizer" on "trip";
  "share" if "organizer" on "trip";

  # Inheritance
  "view" if "viewer";
}
Enter fullscreen mode Exit fullscreen mode

No need to rip out my old RBAC logic — just layer on the new rules where needed.

The Developer Experience

The best part? Evolving my policies was natural and low-friction. I didn’t have to scatter new permission checks throughout the codebase or worry about breaking existing features. Oso let me keep all my authorization logic in one place, so I could focus on the new relationships and permissions without losing sleep over the old ones. Because I created a custom middleware to handle the Oso permission checking, I simply needed to add it to the route definition with the resource and permission.

router.post(
  "/",
  zValidator("param", tripParamSchema),
  withOsoAuth("Trip", "expense.create"),
  zValidator("json", createExpenseSchema),
  async (c) => {
    const db = c.get("db");
    const oso = c.get("oso");
    const user = c.get("user");
    const { tripId } = c.req.valid("param");
    const expenseData = c.req.valid("json");

    try {
      const expenseService: ExpenseService = new DefaultExpenseService(db, oso);

      const newExpense = await expenseService.createExpense(
        tripId,
        user!.id,
        expenseData
      );

      return c.json(newExpense, 201);
    } catch (error) {
      console.error("Error adding new expense:", error);
      throw new HTTPException(500, { message: "Internal Server Error" });
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

Moving from RBAC to ReBAC with Oso was more about extending what I already had than starting from scratch. That’s a huge win for developer sanity and for keeping your app’s access control both powerful and maintainable.

Check out the entire project and see what it looked like at each stage in the shared-travel-app GitHub repository.

Conclusion

Building access control for an app always starts simple, but real-world requirements have a way of pushing things further. I began with homemade RBAC, then leveled up to Oso for cleaner, more scalable policies. When the need for more nuanced permissions came along, moving to ReBAC was a natural next step — and Oso made that transition smooth.

The best part is that I didn’t have to throw out what I’d already built. With Oso, evolving from roles to relationships was just a matter of adding new facts and rules, not rewriting everything from scratch. If you’re working on an app that’s growing in complexity, having a flexible policy engine like Oso can save you a ton of time and headaches.

Access control shouldn’t slow you down. With the right tools, you can keep your code clean, your policies clear, and your users happy as your app grows.

Top comments (0)