Designing RBAC + ABAC for a Cashback App
A full worked capstone: taking one concrete cashback application from a feature list to real, deployed authorization. Classify each feature on the granularity spectrum, design its RBAC roles and ABAC attributes, write the actual Cedar, Casbin and OpenFGA policy for each feature, decide where each check runs and test all of it in CI. Every framework from this module, applied to one system from start to finish.
A whiteboard and seven features
Daniel has a whiteboard, a marker and a list of seven features the cashback app has to support by the end of the quarter. The last time someone designed authorization here, it was one role check per screen, and the support console incident from the granularity article came out of exactly that. This time he wants each decision to be deliberate, and he wants to be able to explain every one of them to the auditor who asked the awkward question.
Every article in this module handed you one piece. The XACML article gave you the four components every authorization system is built from. Policy as code gave you three languages to write real rules in. Fine-grained authorization gave you the relationship-graph model for sharing that doesn’t fit a flat rule. The granularity article gave you a way to decide how much precision a decision needs, and decentralized authorization a way to decide where it runs. This article does what none of them could on its own: take one real application and use every piece to design its authorization, from the feature list down to deployed, tested policy.
The application is CashBack Rewards, the merchant loyalty platform this domain has used for its examples. Customers earn cashback on purchases, merchants manage their own transaction records, support agents help customers with account problems, and merchant staff share internal savings reports with their teams. It’s deliberately ordinary. Nothing in it needs exotic technology, and that’s the point: most of what you build will look more like this than like Google Drive’s sharing graph, and the skill here is recognizing which parts, if any, need that kind of machinery.
The feature list
Seven features cover the part of the app’s authorization worth designing on purpose:
- Open the rewards dashboard. Any customer with an active account sees their own cashback summary.
- View your own transaction history. A customer sees the purchases that earned them cashback.
- A support agent views a customer’s account, to help with a ticket.
- A merchant edits a draft transaction, fixing an error before it settles.
- View a savings report shared with your team, inside a merchant’s staff.
- Open the admin panel, for internal operations staff running the platform.
- A global admin reviews a merchant account in either region, now that the company runs in the US and the EU.
There’s nothing unusual about that list. It’s the kind of feature set most apps that serve both businesses and consumers pile up within a year of launch. That’s why it makes a good capstone: the aim isn’t to show off authorization machinery, it’s to show that most of an app’s access control is simple once it’s classified correctly, and only a small part of it needs real weight.
Step 1: classify every feature
Before choosing a single tool, run every feature through the granularity article’s question: does the right answer depend on which specific resource or relationship is involved, for this specific caller?
| # | Feature | Role only? | This resource’s attributes? | A relationship graph? | Layer |
|---|---|---|---|---|---|
| 1 | Open dashboard | Yes (active account) | No | No | Module/role |
| 2 | View own transactions | No | Yes (ownership) | No | Resource |
| 3 | Agent views account | Partly (role) | Yes (ticket, hours) | No | Resource |
| 4 | Merchant edits draft transaction | No | Yes (ownership, status) | No | Resource |
| 5 | View shared report | No | No | Yes (team membership, sharing) | Relationship |
| 6 | Open admin panel | Yes (role) | No | No | Module/role |
| 7 | Global admin, either region | Yes (role) | No | Yes (cross-region reach) | Module + relationship |
One table, and the shape of the whole design is already there before a line of policy exists. Two features are pure role gates. Three are resource-attribute decisions of varying complexity. One is a real relationship graph. And one combines a role gate with a cross-region question that the placement article has to answer, not the granularity one. That’s why classification comes first: the tool choices in the rest of the article follow from this table rather than being made one feature at a time.
Step 2: the RBAC roles
Daniel designs the smallest set of roles that covers the role-dependent part of every feature, and resists adding a role for every job title in the org chart, which is the classic role explosion the RBAC article warned about. He ends up with five.
customer is anyone with an active account, and it opens the dashboard and shows their own transactions. merchant is business staff managing their own transaction records; it has everything a customer has, plus editing its own draft transactions and viewing reports shared with its team. support-agent is internal support staff, allowed to view customer accounts subject to the resource conditions in the next step. admin is internal operations staff, who can open the admin panel and manage platform configuration. global-admin is a handful of cross-region operators with everything admin has, in either region.
Five roles, not fifteen. The role model answers “is this kind of action on the table at all for this kind of caller”, and on purpose says nothing about which specific resource. That question belongs to the next two steps, and keeping it separate matters. Folding “can support agents view accounts” (role) and “can this agent view this account” (resource) into one sprawling rule is how RBAC systems end up with hundreds of near-identical roles, each trying to encode a resource-specific exception that was never the role’s job.
Step 3: the ABAC attributes
Three of the seven features need more than a role. Daniel lists the attributes those decisions actually read, split the XACML way into subject, resource and environment, because that split is what keeps the policies in the next step readable.
From the subject come role, used by every feature; user_id, for the ownership checks in features 2 and 4; assigned_ticket_customer_ids, the customers the agent has open tickets for, used by feature 3; and team_id, which feature 5 uses through a relationship rather than as a direct condition. From the resource come owner_id for features 2 and 4, status, draft or settled, for feature 4, and customer_id for feature 3. From the environment come current_time, for feature 3’s business hours, and region, for feature 7.
Nine attributes, most of them reused by more than one feature, make a Policy Information Point surface a team can actually manage. Every one of them can be named, given a source and kept fresh, which is the operational question the XACML article raised about PIPs and the fine-grained article raised about relationship data: an attribute or a tuple is only as trustworthy as the system that keeps it current.
Step 4: write the policy, feature by feature
Features 1 and 6: pure role gates
Opening the dashboard and opening the admin panel need nothing beyond a role check: no PDP round-trip, no attribute lookup, just a claim already in the caller’s session token. In a service with Casbin embedded, it’s about the simplest matcher the language can express:
[request_definition]
r = sub, act
[policy_definition]
p = sub, act
[matchers]
m = r.sub == p.sub && r.act == p.actp, customer, open_dashboard
p, merchant, open_dashboard
p, admin, open_admin_panel
p, global-admin, open_admin_panelThere’s no obj column at all. These two features have no resource to reason about, which is exactly what makes them module-layer rather than resource-layer. Bringing a heavier engine in here would be the over-modeling the granularity article warned about.
Feature 2: view your own transactions
The first resource-attribute decision: the caller has to own the transaction they’re asking for. Cedar’s permit and forbid shape, deliberately limited so it stays analyzable, states an ownership condition cleanly:
permit (
principal,
action == Action::"viewTransaction",
resource
) when {
resource.owner_id == principal.user_id
};Feature 3: a support agent views an account
This is the support console from the granularity article, finally written as a real policy. The support-agent role permits the kind of action, but this particular view requires an open ticket assigned to the agent and business hours:
permit (
principal in Role::"support-agent",
action == Action::"viewAccount",
resource
) when {
resource.customer_id in principal.assigned_ticket_customer_ids &&
context.current_time.hour >= 9 &&
context.current_time.hour < 18
};Here the resource layer earns its cost. The role from Step 2 is necessary but not enough, and the two extra conditions are the whole difference between “any agent, any account, any time” and the least-privilege behavior the business actually needs. Make it after hours, or an account with no assigned ticket, and the same policy denies, with no second rule to maintain. If the retail partner’s dedicated team from the granularity article comes back, it’s one more condition on the same policy, not a new role.
Feature 4: a merchant edits a draft transaction
Ownership again, plus one more condition: a settled transaction must never be editable, whoever owns it. It’s small enough to stay in Casbin’s embedded model rather than going to Cedar, because the check has no reason to leave the service that owns the transaction data:
[request_definition]
r = sub, obj_owner, obj_status, act
[policy_definition]
p = act, allowed_status
[matchers]
m = r.sub == r.obj_owner && r.act == p.act && r.obj_status == p.allowed_statusp, edit_transaction, draftThe matcher compares the caller with the resource’s owner directly (r.sub == r.obj_owner, passed in by the calling service rather than looked up by Casbin) and pins the allowed status to draft. A settled transaction has no matching policy row, so it falls through to the default deny. It’s the same idea of ownership as feature 2 with a different tool, because this decision lives entirely inside one service and there’s no reason to pay a network hop to a separate PDP.
Feature 5: view a savings report shared with your team
This is the one feature on the list that really is a relationship and not an attribute. A report shared with a team is visible to everyone on it, however that team’s membership is organized, which is the shape OpenFGA was built for:
type user
type team
relations
define member: [user]
type report
relations
define viewer: [user, team#member]{"user": "user:sara", "relation": "member", "object": "team:merchant-42-staff"}
{"user": "team:merchant-42-staff#member", "relation": "viewer", "object": "report:q3-savings"}sequenceDiagram accTitle: Checking a shared-report view through team membership accDescr: The diagram shows a client asking OpenFGA to check whether Sara can view the Q3 savings report. OpenFGA reads the report's viewer tuple, which names the merchant staff team's member relation as a userset rather than a direct user. OpenFGA expands that userset by reading the team's member tuples and finds Sara listed as a direct member. Because a path was found through one level of team membership, OpenFGA returns Allow to the client. Client->>OpenFGA: Check(report:q3-savings, viewer, sara) OpenFGA->>OpenFGA: read viewer tuple OpenFGA->>OpenFGA: expand team's member tuples OpenFGA->>OpenFGA: Sara found as direct member OpenFGA-->>Client: Allow (path found)
Everything else on the list could be handled without a relationship engine, and was. This one couldn’t, because team membership and sharing nest in ways flat rules can’t express cleanly. That’s the discipline the granularity article argued for: bring in the heavier tool only where the question really is a graph.
Feature 7: a global admin across regions
This one is a role gate, global-admin, with a placement consequence rather than a new policy shape. The interesting decision isn’t what to check, it’s where the data the check reads actually lives, which is the next step.
Step 5: where each check runs
Every feature now has a policy. Placing them follows the decentralized authorization article directly.
The role checks for features 1 and 6 cost almost nothing: the claim is already in the token and can be verified at the edge or at the first gateway, with no PDP call. Feature 2’s Cedar policy runs in a sidecar next to the transactions service, because owner_id is local application data with no reason to leave that service. Feature 3’s Cedar policy runs the same way, in a sidecar next to the support console’s backend, where the ticket-assignment data already lives. Feature 4’s Casbin check runs in process, embedded in the transactions service, which is exactly the deployment Casbin was made for; it’s a plain function call and needs no sidecar. Feature 5’s OpenFGA Check runs against a shared, region-aware OpenFGA deployment, because relationship data spans users and teams in a way no single service owns.
Feature 7 is the one that forces a real decision. The role claim is cheap to check anywhere, but the relationship and resource data a global admin touches lives in two stores partitioned by region. Of the options in the decentralized authorization article, the app accepts the latency of at_least_as_fresh for the rare global-admin path, instead of paying full cross-region write latency on every regional write or partitioning away the one role that legitimately has to cross the boundary. It’s written down as a known exception.
sequenceDiagram accTitle: One request traced through the perimeter, module, and relationship layers accDescr: The diagram shows a client request for a shared report first hitting an edge or gateway perimeter check that verifies the token is valid. The request then reaches a module layer role check confirming the caller has the merchant role, which permits the action type in principle. Only after both cheap checks pass does the request reach the relationship layer, where OpenFGA checks whether the specific report was shared with a team the caller belongs to. If the relationship check finds a path, the service returns the report; otherwise it denies. Client->>Gateway: GET report (with token) Gateway->>Gateway: perimeter: token valid? Gateway->>Service: forward (role: merchant) Service->>Service: module: role permits viewing reports? Service->>OpenFGA: Check(report, viewer, user) OpenFGA-->>Service: Allow (path found) Service-->>Client: report contents
Step 6: test all of it in CI
Each language above comes with its own way of testing, and the policy-as-code discipline applies whichever tool wrote the rule: a broken authorization change should fail in a pull request, not in production.
The Cedar policies for features 2 and 3 get a Cedar test suite validated against the app’s schema, plus the analysis tooling checking that the two permit rules never overlap in a way that grants more than intended. The Casbin models for features 1, 4 and 6 get ordinary unit tests in the host language, asserting that specific requests resolve to the expected allow or deny. The OpenFGA model for feature 5 gets an .fga.yaml file of assertions: with Sara’s tuples, Check(report, viewer, sara) must be true; with the membership tuple removed, it must be false; and for an unrelated user it must be false from the start.
flowchart LR
accTitle: A single CI pipeline testing three different policy languages before publishing
accDescr: The diagram shows a pull request triggering three parallel test jobs, one per policy language used in this app. The Cedar test job runs the Cedar validator and analysis toolchain against the Cedar policies. The Casbin test job runs ordinary unit tests in the host language against the Casbin models. The OpenFGA test job runs the fga test command against the assertion file. All three jobs must pass before the pipeline proceeds to publish a new policy bundle, which is what the decentralized authorization article's sidecars and edge workers eventually pull.
PR[Pull request: policy change] --> Cedar[Cedar validator + analysis]
PR --> Casbin[Casbin unit tests]
PR --> FGA[OpenFGA .fga.yaml assertions]
Cedar --> Gate{All pass?}
Casbin --> Gate
FGA --> Gate
Gate -- yes --> Publish[Publish policy bundle]
Gate -- no --> Block[["Block merge"]]Shortcuts the design turned down
Three shortcuts are common enough in real systems to name, because each one looks perfectly reasonable at exactly the step where this design slowed down.
The first is skipping classification and defaulting to the newest tool. It would have been easy to model the whole app in OpenFGA, including the two pure role gates. Technically it works, but every request would pay for a graph traversal to answer a question that was always “is this caller’s role X”, which a token claim answers on its own. Classifying first is what prevented that.
The second is encoding resource conditions in the role model. A tempting shortcut for feature 3 would have been a support-agent-with-open-ticket role, granted and revoked ticket by ticket. That turns a role table meant to stay small and stable into one that changes with every ticket assignment. It’s role explosion again, brought back by asking RBAC alone to answer a question that needed ABAC on top.
The third is testing each policy language on its own and never testing the layering. A pipeline that runs Cedar, Casbin and OpenFGA tests but never confirms that feature 3’s role check runs before its attribute check, so a malformed role claim can’t slip through to a resource check that assumes a valid role already passed, is missing exactly the test the granularity article called the common gap.
Recap
One application, every framework from this module, in order. Daniel classified before choosing tools, and seven features, each asked one question, sorted themselves into layers before any policy existed. The RBAC model stayed small on purpose, five roles that say whether a kind of action is on the table and nothing about which resource. The ABAC attributes are a short, reusable list with known sources, nine of them covering three separate resource decisions. The policy language followed the layer rather than the other way round: Casbin for embedded role gates and the ownership-and-status check, Cedar for analyzable resource decisions, OpenFGA for the one genuine relationship. Placement was a separate decision from the policy itself, because the logic of a Cedar policy doesn’t change with where it runs, but its latency and consistency do. And every language is tested in the same pipeline, which is only as trustworthy as its power to block a merge.
Three questions to test yourself
- Feature 3 uses a role check and two attribute conditions. Explain what would go wrong, as a security gap or as an unmanageable role table, if you expressed it with RBAC only, and separately what would go wrong with ABAC only and no role check at all.
- Explain why feature 4 was implemented in Casbin rather than Cedar, using this article’s placement and granularity reasoning rather than just “because it’s simpler”.
- Someone proposes a new feature: “merchants can see an aggregate cashback report across every team in their region.” Using the classification table’s three questions, work out which layer it belongs to and which of the app’s existing tools, or a new one, would fit.
Hands-on exercises
- Classify your own app’s features. Pick five to seven real features from an application you use or are building and build this article’s classification table for them, role only, resource attributes or relationship, before choosing any tool.
- Write one policy for a resource-attribute feature. In Cedar or Casbin, write the actual policy for one feature from your table that depends on ownership or status, following the shape of feature 2 or feature 4.
- Design the CI gate for your policy. For the same feature, sketch what a passing and a failing test look like and what the pipeline should block on before the policy reaches production, using Step 6 as the template.
Frequently asked questions
Why design RBAC and ABAC together instead of choosing one?
Because real applications don't live on one granularity layer. Most features in this article's cashback app are correctly modeled as a role check, a few need attribute conditions on top of that role, and one needs a full relationship graph. RBAC answers 'does this caller's role permit this kind of action at all', ABAC answers 'does it depend on facts about this specific request', and treating them as rivals instead of complementary layers is exactly the mistake the granularity article warned about.
How do you decide which policy language to use for which feature in a real system?
Classify the feature's granularity first, then match the tool to that layer. A pure role gate is a claim or a Casbin matcher; a resource-attribute decision that benefits from formal analysis suits Cedar; a decision embedded in one service with no network hop suits Casbin; a decision that depends on a relationship graph is OpenFGA. This article works through all four for one application so the mapping is concrete.
Do I need OpenFGA (or Zanzibar-style ReBAC) for every app that has a 'share' feature?
Only if sharing can nest or fan out in ways you can't list as flat rules: a group shared with a group, a folder inside a folder. The cashback app in this article has exactly one such feature, a shared savings report, and every other feature, including several about ownership, is handled correctly and more cheaply with a resource-attribute check. Reaching for ReBAC every time the words 'shared with' appear is the over-modeling mistake the coarse-vs-fine-grained article named.
How do RBAC roles and ABAC attributes actually combine in one request?
The role answers whether the caller's job permits this kind of action in principle; the attributes answer whether this specific request meets the conditions that action requires. A support agent's role permits viewing customer accounts in general, which is RBAC. Whether they may view this account right now depends on an open ticket assigned to them and on business hours, which is ABAC on top. Neither is enough alone; together they're the resource-layer check from the granularity article, written here as a real Cedar policy.
Where do all these different checks (role, attribute, relationship) actually run in production?
Following the placement article: perimeter and module checks (token validity, role gates) are cheap enough to run at the edge or in a mesh sidecar; resource-attribute checks run in a per-service OPA or Cedar sidecar, or in process, because they need data local to the application; and the relationship check for the one ReBAC feature runs against a region-aware OpenFGA deployment, because its data has real consistency and residency requirements. The article places each of the app's features explicitly.
What's the single biggest mistake teams make applying this in a real app?
Skipping classification and picking a tool by habit or hype: building a relationship graph for a feature that was always going to be a role check, or hard-coding a role check for a feature that quietly needed per-resource ownership and grew into a security gap nobody noticed until an audit. The fix this module has been building towards is mechanical: classify every feature explicitly before choosing its tool, as this article does for all seven of the app's features.