How to Design an Access-Control Model (RBAC) With AI
Both of them logged in successfully. One of them is looking at a ticket from another company's workspace. Here's how to turn your real actors, resources and actions into a reviewable RBAC model and a permission matrix where every cell says allow, deny, or on what condition.
Build a Permission Matrix PromptBoth of them logged in. Only one should see this ticket.
Authentication worked perfectly. The token is valid, the session is fresh, the audit log says a real human signed in from a real device. And they are reading a support ticket belonging to a different company, because somewhere in the code a query fetched a ticket by id and nobody asked the second question. Knowing *who* someone is settles nothing about *what they may do to which record* — that's a separate decision, made in a separate place, and it's the one that gets skipped, because it doesn't have a login screen to remind you it exists. This guide is about that second decision: authorization. Not login, not passwords, not sessions or tokens — those are how identity gets established, and they're a different problem with a different failure mode.
The thing worth designing before you write any of it is a model: the actors your system really has, the resources worth protecting, the actions people take on them, and — for every pair — whether the answer is yes, no, or *it depends, and here's exactly what it depends on*. AI is useful here in a specific way: it will hold the shape, force a decision into every cell, and notice the row you left blank. It is not doing security. It works only from what you type — your actors, your roles, your ownership rules, your tenant model, your constraints — and NewPrompt is where that prompt gets built, in your browser. Nothing connects to your application, your database, your identity provider or your policy engine; nothing discovers the permissions you already have; nothing reads your authorization code; nothing enforces anything at runtime. It is not a vulnerability scanner and it does not find the flaw in what you built. The matrix that comes back is a candidate for review, not an approval — the model cannot know about the actor you forgot, the integration that authenticates as a service account, or the one workflow where support genuinely does need to see the record. A security owner or a qualified reviewer signs off on the model and on the code that implements it, and nothing here promises the result is least-privilege or secure.
A role is not a permission
The reason most access-control models rot is that they start at the wrong end. Someone lists the roles first — admin, user, maybe manager — and then spends three years deciding what each one means, one exception at a time, in whatever code path happens to need it. Roles are a *convenience*: a name for a bundle of permissions that tends to go together because a real job needs them together. Start with the bundle and the name is arbitrary. Start with the name and you've committed to a shape before you know what's in it.
So the order matters. Permissions first — a discrete verb against a resource, existing whether or not any role has it yet. Then roles, as groupings that map to jobs people actually hold. Then the mapping between them, which is the artifact you can hand someone. That order also makes the interesting failures visible, because they all live between the layers: the permission no role has (who is supposed to do this?), the role with a permission its job never needs, the two roles that differ by one cell and should probably be one role and a condition. None of that is findable if you started from three role names and worked outward.
It also exposes the thing RBAC alone genuinely cannot say. "An agent can view tickets" is a role statement. "An agent can view tickets *in their own workspace*" is a role plus a condition — and if you try to express that condition by inventing a role per workspace, you'll have four hundred roles by next year and no idea what any of them do. The condition isn't a role. It's a property of the request, and the model needs somewhere to put it that isn't the role name.
Step 1: Enumerate the permissions before you name a single role
Write the verbs. Not create/read/update/delete — those are database operations wearing a domain's clothes, and they hide exactly the distinctions that matter. "Update ticket" is one permission in CRUD and at least four in reality: reassign it, close it, reopen it, redact a customer's data out of it. Those have different risks, different people who should hold them, and different consequences when they're wrong. The moment you write "update," you've granted all four to whoever gets it. So enumerate the actions the way the business talks about them, and let the list be longer than feels tidy.
Then the resources, and be suspicious of the ones that look like one thing. A comment on a support ticket is two resources: the public reply the customer reads, and the internal note where an agent wrote "this account is a nightmare, escalate carefully." Same table, same shape, completely different access rules, and a model that treats them as one resource has already leaked. Remember the resources that aren't the product, too — the audit log, the settings page where roles are defined. Those carry permissions like anything else, and leaving them out of the enumeration is how they end up ungoverned.
Then the actors — including the ones that aren't people. The nightly export job, the integration that authenticates as a service account, the support engineer who is technically staff but not on this team. Every actor you don't list is an actor whose permissions get decided by accident. The RBAC Design Prompt is a copy-paste prompt built on exactly this order — its first instruction is to enumerate the discrete permissions as verb-plus-resource, independent of roles, and its second is to group them into roles that map to real responsibilities rather than to admin-and-user. You adapt it and run it in your own AI assistant.
Step 2: Group the permissions into roles that match real jobs
Now bundle. A role should correspond to something a person is actually hired to do — the thing you'd write on their first day, not a tier in a hierarchy. If you can't say what job a role has, it isn't a role, it's a leftover. And every permission needs at least one role that holds it, because a permission nobody has is either dead code or a workflow that currently happens through someone's personal database access.
Two shapes cause most of the trouble here, and it's worth naming them while the model is small enough to fix. The first is inheritance. "Team lead is an agent plus these three" is compact and it silently means the team lead gets every permission the agent role ever gains, including ones added two years from now by someone thinking about agents. Enumerate instead: say what the role holds, in full, even when the list overlaps another role's. It's longer and it doesn't surprise you. The second is the near-duplicate: two roles differing by one cell. That's usually one role and a condition wearing a costume, and merging them is cheaper now than after both are in production and each has picked up its own exceptions.
Ask the model to justify every role by naming its job, and to flag any role holding a permission that job doesn't need. That second one is where the useful pushback comes from — it's mechanical, it's tedious, and it's the check nobody does by hand once the matrix is bigger than a screen. It's also the check that catches the mismatch that matters most: a role whose one-line description quietly omits its most dangerous permission.
Step 3: Make every cell say allow, deny, or on what condition
The matrix is roles across the top, actions down the side, and a decision in every single cell. Not a checkmark and a blank — a blank is the problem. A blank cell means either "no" or "nobody thought about it," and those look identical on the page and behave very differently in code, where the second one usually means yes. So the rule is that a cell can say ALLOW, or DENY, or CONDITIONAL with the condition written out in words: *own records only*, *same team only*, *only while assigned*. "Conditional" without the condition is a blank with better manners. And check the axis against your permission list when you're done — a permission you enumerated and never gave a row is the same blank, one level up.
The Permission Matrix Prompt is a copy-paste prompt for that artifact, and its shape is the discipline: roles by actions, allow/deny/conditional in every pair, a scope column saying whether an allow means *all* records or only owned ones, and a findings section that flags over-grants and missing denies. The scope column is the part people skip and the part that catches the incoherent row — the role that may close a ticket it isn't allowed to see. The Markdown Output Builder is the tool that builds that prompt — its permission-matrix setup already carries those sections, requires a real table rather than prose, and holds the section order strictly, which matters for an artifact somebody is going to read cell by cell in a review. It emits prompt text; you run it in your own AI tool.
Two things belong in the matrix that people leave in their heads. Default deny: say out loud what happens to a role-action pair nobody considered, because the answer your framework gives when no rule matches is the answer your system has, and it should be one you chose. And explicit denies, which are not the same as an absent allow — "support can read customer records but must never export them" is a rule that survives someone later granting support a broader role. An absent allow disappears the moment the grant changes; a written deny argues back.
Step 4: Draw the tenant boundary and the ownership line
Two conditions do most of the real work in a modern application, and neither of them is a role. The first is tenancy: an agent in one customer's workspace must never see another's, no matter what role they hold. That isn't a cell in the matrix — it's a predicate on every cell, which is why it should be stated once, as a rule every data access obeys, rather than remembered per endpoint. Keeping it outside the table matters practically, not just tidily: once it's outside, a plain ALLOW already means *within this tenant*, and any condition still written in a cell is a genuinely narrower rule rather than a restatement of the global one. The Multi-Tenant Access Control Prompt is a copy-paste prompt for that specific decision: which isolation model you're choosing and what it trades away, and the single scoping rule every query has to satisfy. A model with a perfect role hierarchy and a missing tenant predicate is a data breach with good documentation.
Watch for the actor the predicate can't hold. Every so often there's one that has no tenant — a reporting job that runs across all of them, an internal tool that answers support escalations. "Every access is scoped to the actor's own workspace" is simply not true of it, and the right move is to say so in the model rather than let the exception live in a code path nobody reviews. An actor that sits outside your isolation rule is the highest-value thing in the whole design to name out loud.
The second condition is ownership, and it's the one that tempts people into role explosion. "A requester can view their own tickets" is a condition on the actor's relationship to the record, and there are only two honest ways to hold it: as a condition in the cell, or as a role per person, which is not a plan. Same for *assigned to me*, *created by me*, *in my team*. Write them as conditions, name them precisely, and notice when a condition is doing so much work that the role has become decoration.
The judgment worth making explicitly is where simple RBAC stops being enough. If most cells are unconditional and a handful carry *own records only*, roles plus conditions is a fine model and staying there is correct. If nearly every cell has a condition and the conditions differ per cell, the roles aren't carrying the decision anymore and something more expressive is warranted — but that's a real cost, and reaching for it early is how a support tool ends up with a policy language nobody on the team can read. The model can lay both out; which one your system needs is a call about your domain and your team, and it isn't one an AI is in a position to make.
Step 5: Ask who hands out the roles
Granting a role is itself an action on a resource, and it's the one most likely to be missing from the matrix — because it doesn't feel like part of the product. It is. If a team lead can assign roles, and one of the assignable roles is workspace admin, then team lead *is* workspace admin, in one extra step, forever. That's not a vulnerability someone has to find; it's the model saying so, and it's visible the moment the grant action is a row like any other. So put it in the matrix, and give the row two things a normal row doesn't need: what may be granted, and *to whom* — including whether an actor may grant to themselves, which is the difference between a lead who can staff their team and a lead who can promote themselves.
The rule that closes most of it is that nobody grants a permission they don't hold. It's worth applying rather than just stating, because it derives the answer instead of asserting one: a team lead holds everything an agent holds, so they can grant agent; they don't hold the settings and export permissions the workspace admin has, so they can't grant that. The cell writes itself. But apply it honestly and you'll find the place it breaks — a role whose entire job is administering people usually holds *none* of the permissions it hands out, so a pure closure rule says it can grant nothing at all. That's a real limit, not a flaw in the example, and the answer is an exception someone wrote down on purpose rather than an exception that emerged in code.
Two related things belong here. Separation of duties: some pairs of permissions shouldn't sit in one role even though both are legitimate, because the point of one is to check the other — the person who can redact customer data shouldn't also be the person who can delete the audit log that records the redaction. Write it even when no current role holds both; the constraint isn't describing today's matrix, it's governing the next one. And temporary access, which every system needs and few model: an engineer needs to see one record to debug one problem. If there's no designed way to do that, it happens through a shared admin account, which is worse. Design the exception — narrow, time-limited, logged, and with a named person who reviews it — and check that someone in the model can actually *read* the log you're relying on.
Step 6: Walk real requests through it
A matrix reads as finished long before it is, because every cell is filled and the eye takes that for coverage. Test it the way you'd test anything else: with scenarios, run one at a time, against the table rather than against your memory of what you meant. A requester opening a link to someone else's ticket. An agent reassigning across teams. A support engineer who needs one record for one hour. A departed employee whose role was never removed. A service account with no human attached and no tenant to belong to.
What you're looking for is the answer "nothing in here covers that," and you want to hear it now rather than from a customer. The scenarios that find the most are the ones with a *relationship* in them rather than a role — the actor who is legitimately staff but on a different team, the record that's shared with a second workspace, the person who was an agent yesterday. Roles alone answer none of those, and that's diagnostic: it tells you which cells need a condition and which conditions have gone from two to fifteen and are asking for a different model.
This is also the point where the artifact stops being yours. A matrix nobody has reviewed is a document; a matrix a security owner has argued with is a decision. Hand it over with the open questions still open rather than resolved on your own authority — the ones where two answers are both defensible are precisely the ones the person accountable for the data should settle, and they're the ones a model will happily settle for you if nobody stops it.
Common mistakes
The failure mode here is silence — nobody files a ticket about the record they saw and shouldn't have:
- Treating a valid login as permission. Authentication answers who; authorization answers whether. A system that checks the first and assumes the second is the most common access-control bug there is, and it looks like working software.
- Letting CRUD stand in for domain actions. Update is not one permission. Reassign, close, reopen and redact carry different risk and different holders — collapse them and you've granted the dangerous one along with the routine one.
- Blank cells, and enumerated permissions with no row at all. Both are unanswered questions that read as decisions, and the framework will answer them for you when no rule matches.
- Encoding a condition as a role. One role per team, per workspace, per project — that's role explosion, and it starts as a reasonable-looking shortcut around writing *same team only* in a cell.
- An action scope wider than the read scope. A role that may close a ticket it isn't allowed to view is not a subtle bug, and it's invisible without a scope column to line the two rows up against each other.
- Leaving role assignment out of the model. If granting isn't a row — with a to-whom — nobody has decided whether a lead can promote themselves, and the answer will be discovered rather than chosen.
- Confusing the UI with the rule. Hiding a button is not access control — it's a hint. The check has to happen where the action does, and a model that only says what people see has designed nothing.
- Reading the matrix as an approval. It's a candidate the security owner reviews — NewPrompt doesn't scan your code, discover your permissions, or enforce a single rule at runtime.
Modelling access for a multi-tenant support desk
A support product serving many customer companies, worked from permissions up to a matrix with the conditions written in.
Fourteen permissions enumerated before any role exists, roles written out in full rather than inherited, and a matrix where the tenant rule sits outside the table, the grant cells are derived from a closure rule rather than asserted, and the one actor that rule cannot cover gets namedACTORS (including the ones that aren't people)
requester an end customer who files tickets
agent staff who answer them
team lead an agent's work plus running a team
workspace admin configures the workspace, manages people
export job a service account, nightly, no human attached
RESOURCES (note the split -- this is where a model leaks)
ticket
public reply the customer reads this
internal note "this account is difficult, escalate carefully"
^ same table as public reply. NOT the same resource.
audit log
workspace settings (this is where role definitions live)
PERMISSIONS (verb + resource, named before any role exists)
submit ticket | view ticket | reply publicly | add internal note
read internal note | reassign ticket | close ticket | reopen ticket
redact customer data | export tickets | read audit log
delete audit entry | configure workspace settings | grant role
what CRUD would have hidden: reassign / close / reopen / redact are
all "update ticket". four risks, four holders, one word.
ROLES (each named for a job, and enumerated -- not inherited)
requester files and follows their own tickets
agent answers tickets for their team
team lead runs a team: answers tickets across the workspace,
reassigns, and holds redaction
^ NOT "agent + 3". the permissions are listed below,
in full. inheritance would silently hand this role
whatever agent gains in 2028.
workspace admin configures the workspace and its people
(no "superadmin". nobody's job is everything.)
MATRIX (14 permissions, 14 rows. every cell decided.)
ALLOW already means "within this workspace" -- that's the tenant
predicate below, not a cell condition. only the COND cells are
narrower than it.
action \ role | requester | agent | team lead | ws admin
submit ticket | ALLOW | DENY | DENY | DENY
view ticket | COND: own | COND: team | ALLOW | ALLOW
reply publicly | COND: own | COND: team | ALLOW | DENY
add internal note | DENY | COND: team | ALLOW | DENY
read internal note | DENY (!) | COND: team | ALLOW | OPEN Q
reassign ticket | DENY | COND: team | ALLOW | DENY
close ticket | COND: own | COND: team | ALLOW | DENY
reopen ticket | COND: own | COND: team | ALLOW | DENY
redact cust. data | DENY | DENY | ALLOW | DENY
export tickets | DENY | DENY | DENY | ALLOW
read audit log | DENY | DENY | DENY | ALLOW
delete audit entry | DENY | DENY | DENY | DENY
configure settings | DENY | DENY | DENY | ALLOW
grant role | DENY | DENY | COND (a) | COND (b)
(!) explicit deny, not an absent allow. it survives someone later
widening the requester role.
COND: own = records where the actor is the requester
COND: team = tickets assigned to the actor's own team
DEFAULT: any pair not on this table is DENY. stated, not assumed.
note the scope discipline: agent is COND: team on EVERY action,
including the ones that aren't reads. an agent who could close a
ticket they cannot view would be an incoherent row -- and that is
exactly the row a matrix without a scope column hides.
TENANT BOUNDARY (a predicate on every cell, not a row)
every cell above is additionally scoped: an actor may only ever
touch records in their own workspace. one rule, every data access,
stated once. if it is missing, the matrix above is decoration.
THE ACTOR THE PREDICATE CANNOT HOLD
export job a service account. nightly. no human, and -- this is
the point -- NO WORKSPACE.
export tickets | ALLOW, across all workspaces
everything else | DENY
"every actor is scoped to their own workspace" is simply false of
this one. it is not an exception to note in the margin: it is the
single actor in the system that the isolation rule does not cover,
and it either gets a written carve-out with its own review, or it
gets given a workspace per run so the rule holds again. deciding
that in the model is the whole reason to have listed it as an actor.
GRANT ROLE -- derived, not asserted
the closure rule: nobody grants a permission they do not hold.
(a) team lead. holds everything agent holds; does not hold submit,
export, read audit log, or configure settings.
-> may grant: agent. only. and not to themselves.
nothing was decided here -- the rule derived the cell.
this is also what closes the escalation: if a lead could grant
workspace admin, a lead would BE a workspace admin one step
later. they cannot, because they hold none of what it holds.
(b) workspace admin. and here the closure rule breaks, honestly:
the admin holds no ticket permissions at all, so the rule says
they may grant nothing -- including the agents they exist to
onboard. a role whose job is administering people usually holds
none of what it hands out.
-> COND: may grant requester and agent, to anyone but themselves.
a WRITTEN exception to the closure rule, because delegated
administration is the job. exceptions like this are fine.
exceptions that emerge in code are not.
SEPARATION OF DUTIES
no role holds delete audit entry today -- and that is the point.
the rule is not describing this matrix, it is constraining the next
one: when someone proposes an "audit cleanup" role in year two, this
line is what says it cannot also carry redact. an absent allow
disappears the moment a grant changes; a written constraint argues.
TEMPORARY ACCESS
an engineer needs one ticket to reproduce one bug. designed, not
improvised: scoped to a ticket id, expires in 24h, written to the
audit log, reviewed monthly. note the dependency -- that review only
works because ws admin holds read audit log. a control that assumes
someone can read a log nobody can read is a control on paper.
WHAT THE MATRIX CANNOT SAY
does "export tickets" include the internal notes? the resource split
says they are different things; the export row does not mention it.
a matrix gives you cells, not composition -- and this one may have
quietly answered the open question below via a different row.
OPEN QUESTION -- the security owner's call, not the model's
may a workspace admin read internal notes on tickets they are not
on? "they administer the workspace" argues yes. "internal notes are
where agents write candid things about customers" argues no. both
are defensible, it changes a cell and possibly the export row, and
the person accountable for the data decides it before anyone writes
a middleware check.
Where this fits in NewPrompt
This sits inside a longer arc, and the AI Auth & Identity Workflow is that arc: choose the authentication approach, model the roles and permissions, review the access-control design for gaps, then document the identity model. Its first step is authentication — the part this guide deliberately doesn't cover — and its remaining three are this guide's job, which is why the resources it names for those steps are the same ones here: the RBAC Design Prompt for the model, the Authorization Review Prompt for the gap review, and the Permission Matrix Prompt for the matrix. It supplies each step's prompt and order; you run them in your own AI tool. Its own words on where the line falls are worth keeping: it does not certify the result or replace a security audit or penetration test — the AI assists your reasoning, it does not sign off. And it points reviewing existing code for vulnerabilities somewhere else entirely, which is the right instinct.
That boundary is worth holding onto, because this guide lives entirely on the design side. Once code exists, the question changes from "is this model right" to "does the code do what the model says" — a different job with a different failure mode, and the Authorization Review Prompt is the copy-paste prompt for that one: reviewing pasted code for missing per-resource checks and for treating "logged in" as "allowed." Reach for it after the model exists, not instead of building one. The other neighbour is the review lens on the design itself — the Role Prompt Generator builds prompts that put the model in a named expert's seat, which is how you get a security-shaped critique of a matrix rather than a general one.
If the access-control model is for a backend you're building, the Build an API Backend with AI project is the fuller path this sits inside. Its design phase runs architecture, then the API contract, then the data model, then this — "decide how callers prove who they are and what each may do — authentication, roles, and permissions — and review the design for gaps before the endpoints exist to exploit." The project frames that order; it doesn't write or deploy the backend for you, and the access-control calls stay yours.
The reason to build the matrix at all isn't the design — it's that afterwards, "who can do X?" has an answer somebody can check. Most teams can't answer that question about their own product. The knowledge is distributed across a middleware file, a couple of `if` statements, a frontend that hides a button, and one person who remembers why. That's not a security posture; it's a rumour. A matrix turns access into something a reviewer can read, an auditor can ask about, and a new engineer can be wrong about *visibly*, which is the only kind of wrong you can fix. Getting the model onto a page is quick. Noticing that the export job has no workspace, and therefore sits outside the one rule the whole design rests on, is not something a model can do for you — it's what you find by listing the actors nobody thinks of as actors.