Designing RBAC + ABAC for a Cashback App
A full worked capstone: taking one concrete cashback application from a feature list to real, deployed authorization — classifying each feature on the granularity spectrum, designing its RBAC role model and ABAC attributes, writing the actual Cedar, Casbin, and OpenFGA policy for each feature, deciding where each check runs, and testing all of it in CI. Every framework from this module, applied to one system, end to end.
The module, applied to one system
Every article in this module has handed you a 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 framework for deciding how much precision a given decision actually needs. Decentralized authorization gave you a framework for deciding where each decision actually runs. This article does the thing none of the others could: 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 whole domain has used for worked examples — customers earn cashback on purchases, merchants manage their own transaction records, support agents help customers with account issues, and teams of merchant staff share internal savings reports. It is deliberately ordinary: nothing about it requires an exotic technology, which is the point — most of what you build will look more like this than like Google’s Drive-scale sharing graph, and the skill this article teaches is recognizing which parts, if any, actually need that scale.
The feature list
Seven features cover the authorization surface of the app worth designing deliberately:
- Open the rewards dashboard — any customer with an active account should see their own cashback summary.
- View own transaction history — a customer sees the purchases that earned them cashback.
- Support agent views a customer’s account — to help with a support ticket.
- Merchant edits a draft transaction record — correcting an error before it settles.
- View a savings report shared with your team — a merchant’s internal team, sharing analysis across staff.
- Open the admin panel — internal operations staff managing the platform itself.
- A global admin reviews a merchant account in either region — the company now operates in both the US and the EU.
Nothing about this list is unusual — it’s the kind of feature set most B2B-and-consumer-hybrid applications accumulate within a year of launch. That ordinariness is exactly why it’s a good capstone: the goal is not to showcase exotic authorization machinery, it’s to show that most of an app’s access control is simple once it’s classified correctly, and only a little 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 correct answer depend on which specific resource or relationship is involved, for this specific caller?
| # | Feature | Depends on caller’s role only? | Depends on this resource’s attributes? | Depends on a relationship graph? | Layer |
|---|---|---|---|---|---|
| 1 | Open dashboard | Yes (active account) | — | — | Module/role |
| 2 | View own transactions | — | Yes (ownership) | — | Resource |
| 3 | Agent views account | Partially (role) | Yes (ticket, hours) | — | Resource |
| 4 | Merchant edits draft transaction | — | Yes (ownership, status) | — | Resource |
| 5 | View shared report | — | — | Yes (team membership, sharing) | Relationship |
| 6 | Open admin panel | Yes (role) | — | — | Module/role |
| 7 | Global admin, either region | Yes (role) | — | Yes (cross-region reach) | Module + Relationship |
Five distinct layer assignments already fall out of one table, before a line of policy is written. Two features are pure role gates. Three are resource-attribute decisions of varying complexity. One is a genuine relationship graph. One combines a role gate with a cross-region relationship concern the placement article’s framework has to answer, not the granularity framework alone. This is the entire point of doing classification first: the tool choices in the rest of this article are consequences of this table, not independent decisions made feature by feature.
Step 2: the RBAC role model
Design the smallest role set that covers every feature’s role-dependent piece — resist the temptation to add a role for every job title in the org chart, which is the classic RBAC role-explosion mistake the RBAC article warned against:
| Role | Who | Grants |
|---|---|---|
customer | Anyone with an active account | Open dashboard, view own transactions |
merchant | Business staff managing their own transaction records | Everything customer has, plus edit own draft transactions, view reports shared with their team |
support-agent | Internal support staff | View customer accounts (subject to resource-layer conditions below) |
admin | Internal operations staff | Open admin panel, manage platform configuration |
global-admin | A small number of cross-region operators | Everything admin has, in either region |
Five roles, not fifteen. Every feature in the table above maps to exactly one of them needing the role at all — the role model answers “is this action type even on the table for this kind of caller,” and deliberately answers nothing about which specific resource. That question belongs to the next two steps, kept separate on purpose: mixing “can support agents view accounts” (role) with “can this agent view this account” (resource) into one sprawling rule is how RBAC systems accumulate hundreds of near-duplicate roles trying to encode resource-specific exceptions that were never the role’s job to express.
Step 3: the ABAC attributes
Three of the seven features need more than a role to decide correctly. List the attributes those decisions actually consume, split the XACML way — subject, resource, environment — because that split is what makes the policies in the next step readable:
| Category | Attribute | Used by |
|---|---|---|
| Subject | role | every feature |
| Subject | user_id | features 2, 4 (ownership match) |
| Subject | assigned_ticket_ids | feature 3 |
| Subject | team_id | feature 5 (via relationship, not a direct condition) |
| Resource | owner_id | features 2, 4 |
| Resource | status (draft / settled) | feature 4 |
| Resource | customer_id | feature 3 |
| Environment | current_time | feature 3 (business hours) |
| Environment | region | feature 7 |
Nine attributes, most reused across more than one feature, is a manageable Policy Information Point surface — small enough that a team can name, source, and keep fresh every one of them, which is exactly the operational question the XACML article raised about PIPs and the fine-grained authorization 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 present in the caller’s session token. In a Casbin-embedded service this is close to 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_panelNo obj column at all — these two features don’t have a resource to reason about, which is precisely what makes them module-layer and not resource-layer. Reaching for a heavier engine here would be the over-modeling mistake the granularity article named directly.
Feature 2: view own transactions
The first resource-attribute decision — the caller must own the transaction record they’re asking to see. Cedar’s permit/forbid shape, restricted deliberately so it stays analyzable, expresses an ownership condition cleanly:
permit (
principal,
action == Action::"viewTransaction",
resource
) when {
resource.owner_id == principal.user_id
};Feature 3: support agent views an account
The fintech example the XACML article walked through conceptually, now as an actual policy: a support agent’s role permits the action type, but this specific view requires an open, assigned ticket during 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
};This is the resource layer earning its cost: the same support-agent role from Step 2 is necessary but not sufficient, and the two extra attribute conditions are exactly the difference between “any agent, any account, any time” and the least-privilege behavior the business actually requires. Change the scenario to after-hours or an unassigned ticket, and the same policy correctly denies, without a second rule to maintain.
Feature 4: merchant edits a draft transaction
Ownership again, plus one more condition — a settled transaction should never be editable, no matter who owns it. Small enough to stay in Casbin’s embedded model rather than reaching for Cedar, since this check has no need 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 checks caller identity against resource ownership directly (r.sub == r.obj_owner, passed in by the calling service rather than looked up by Casbin itself) and pins the allowed status to draft — a settled transaction simply has no matching policy row, so it falls through to deny by default. Same ownership idea as feature 2, different tool, because this decision lives entirely inside one service with no reason to pay for a network hop to a separate PDP.
Feature 5: view a savings report shared with your team
The one feature on this list that is genuinely a relationship, not an attribute — a report shared with a team is visible to everyone on that team, however the team’s membership is structured, which is exactly the shape the fine-grained authorization article built OpenFGA to answer:
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 this feature list could be, and was, handled without a relationship engine. This one couldn’t — team membership and sharing nest in a way flat rules can’t cleanly express — which is exactly the discipline the coarse-vs-fine-grained article argued for: reach for the heavier tool only where the question actually is a graph, not by default.
Feature 7: a global admin across regions
This feature is a role gate (global-admin) with a placement consequence, not a new policy shape — the interesting decision here isn’t what to check, it’s where the data the check reads actually lives, which the next step covers directly.
Step 5: where each check runs
Every feature above now has a policy. Placing them uses the decentralized authorization article’s framework directly:
- Feature 1 and 6’s role checks are perimeter-cheap — a claim already in the token, verifiable at the edge or the first gateway hop, no PDP call needed at all.
- Feature 2’s Cedar policy runs in a per-service sidecar next to the transactions service, since
owner_idis local application data with no reason to leave the service boundary. - Feature 3’s Cedar policy runs the same way, in a sidecar next to the support console’s backend — the ticket-assignment data it reads is already local to that service.
- Feature 4’s Casbin check runs in-process, embedded directly in the transactions service, exactly the deployment Casbin is built for — no sidecar at all, since it’s a plain function call.
- Feature 5’s OpenFGA Check runs against a centralized (or, per feature 7, region-aware) OpenFGA deployment, since relationship data spans users and teams in ways no single service owns end to end.
- Feature 7’s global-admin case is the one that forces a real decision: the role claim itself is cheap to check anywhere, but the relationship and resource data a global admin’s request touches lives in two region-partitioned stores. Per the decentralized authorization article’s options, this app accepts the
at_least_as_fresh-style latency cost for the rare global-admin path rather than either paying full cross-region write latency on every regional write or fully partitioning away the one role that legitimately needs to cross the boundary.
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 the whole thing in CI
Every language used above ships its own test story, and the policy-as-code discipline applies regardless of which 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 toolchain checking that the two
permitrules never unintentionally overlap in a way that grants more than intended. - The Casbin models for features 1, 4, and 6 get plain unit tests in the host language’s own test framework, asserting specific
(sub, obj, act)triples resolve to the expected allow or deny. - The OpenFGA model for feature 5 gets an
.fga.yamlassertion file: given the Sara-and-team tuples,Check(report, viewer, sara)must betrue; with the membership tuple removed, it must befalse; for an unrelated user, it must befalsefrom 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"]]Mistakes this design deliberately avoided
Three patterns are common enough in real systems to name explicitly, because each one shows up as a plausible-looking shortcut at exactly the step this article took more carefully:
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 — it works, technically, but every request pays a relationship-graph traversal for a question that was always “is this caller’s role X,” answerable from a token claim alone. Classification first is what prevented that.
Encoding resource conditions into the role model. A tempting shortcut for feature 3 would have been a support-agent-with-open-ticket role, minted and revoked per ticket. That turns a role table meant to stay small and stable into one that churns with every ticket assignment — the RBAC role-explosion failure mode, reintroduced by trying to make RBAC alone answer a question that needed ABAC layered on top.
Testing each policy language in isolation and never testing the layering. A pipeline that runs Cedar tests, Casbin tests, and OpenFGA tests but never confirms that feature 3’s role check actually runs before its attribute check — so a malformed role claim can’t accidentally fall through to a resource check that assumes a valid role already passed — is missing exactly the layering test the coarse-vs-fine-grained article called out as the common gap.
Recap
One application, every framework from this module, in order:
- Classify before choosing a tool. Seven features, run through one question each, produced five distinct layer assignments before any policy was written.
- RBAC stays small on purpose. Five roles answer “is this action type on the table,” deliberately saying nothing about which resource — that’s the next layer’s job.
- ABAC attributes are a short, reusable, sourced list, not one bespoke condition per feature — nine attributes covered three separate resource-layer decisions.
- The policy language follows the layer, not the reverse — Casbin for embedded role-only and ownership-plus-status gates, Cedar for analyzable resource-attribute decisions, OpenFGA for the one genuine relationship.
- Placement is a separate decision from the policy itself — the same Cedar policy’s logic doesn’t change based on where it runs, but where it runs (edge, sidecar, in-process, region-aware store) changes its latency and consistency properties.
- Every language gets tested in the same CI pipeline, and the pipeline is only as trustworthy as its ability to gate a merge, not just report a result.
Three questions to test yourself
- Feature 3 (support agent views an account) uses both a role check and two attribute conditions. Explain what specifically would go wrong — either as a security gap or as an unmanageable role table — if you tried to express this feature using only RBAC, and separately, what would go wrong if you tried to express it using only ABAC with no role check at all.
- Explain why feature 4 (merchant edits a draft transaction) was implemented in Casbin rather than Cedar, using this article’s placement and granularity reasoning rather than just “because it’s simpler.”
- A new feature is proposed: “merchants can see an aggregate cashback report across every team in their region.” Using the classification table’s three questions, work out which layer this new feature belongs to, and name which of this app’s existing tools (or a new one) would fit it.
Hands-on exercises
- Classify your own app’s feature list. 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-attribute, or relationship — before deciding on any tool.
- Write one policy for a resource-attribute feature. Using either Cedar or Casbin, write the actual policy for one feature from your classification exercise that depends on resource ownership or status, following the shape of this article’s feature 2 or feature 4.
- Design the CI gate for your policy. For the same feature, sketch what a passing versus failing test would look like, and describe what a CI pipeline should block on before letting that policy reach production — using this article’s Step 6 as the template.
Frequently asked questions
Why design RBAC and ABAC together instead of choosing one?
Because real applications are not one granularity layer — most features in this article's cashback app are correctly modeled as a role check, and 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 action type at all,' ABAC answers 'does it depend on facts about this specific request,' and treating them as competing choices instead of complementary layers is exactly the mistake the granularity-spectrum article warned against.
How do you decide which policy language to use for which feature in a real system?
By classifying the feature's granularity first, then matching the tool to that layer: a pure role gate is a claim or a Casbin matcher, a resource-attribute decision that benefits from formal verification is a good fit for Cedar, a decision embedded directly in one service with no network hop is a good fit for Casbin, and a decision that depends on a relationship graph is OpenFGA. This article works through all four for one application so the mapping is concrete instead of abstract.
Do I need OpenFGA (or Zanzibar-style ReBAC) for every app that has a 'share' feature?
Only if the sharing can nest or fan out in ways you can't enumerate as flat rules — a group shared with a group, a folder inside a folder. This article's cashback app has exactly one such feature (a shared savings report), and every other feature, including several that involve ownership, is handled correctly and more cheaply by a resource-attribute check instead. Reaching for ReBAC everywhere a 'shared with' word appears is the over-modeling mistake the coarse-vs-fine-grained article named directly.
How do RBAC roles and ABAC attributes actually combine in one request?
The role answers whether the caller's job function permits this action type in principle; the attributes answer whether this specific instance of the request satisfies the conditions that action type actually requires. A support agent's role permits viewing customer accounts in general — that's RBAC. Whether they may view this specific account right now depends on an open assigned ticket and business hours — that's ABAC layered on top. Neither layer alone is sufficient; together they're the resource-layer check from the granularity article, expressed as a real Cedar policy in this one.
Where do all these different checks (role, attribute, relationship) actually run in production?
Following the placement article's framework: perimeter and module-layer checks (token validity, role gates) are cheap enough to run at the edge or a mesh sidecar; resource-attribute checks run in a per-service OPA or Cedar sidecar since they need application-local data; and the relationship check for the one ReBAC feature runs against a region-aware OpenFGA deployment since it holds data that has real consistency and residency requirements. This article places each of the app's actual features explicitly rather than leaving placement abstract.
What's the single biggest mistake teams make applying this in a real app?
Skipping the classification step and picking a tool by habit or hype instead — 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 whole module has been building toward is mechanical: classify every feature explicitly before choosing its tool, the way this article does for all seven of the cashback app's features.