Software Development with AI 12 min read Updated Jul 16, 2026

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 Prompt

Both 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 named
ACTORS  (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.

Tools for this guide

Each generates the prompt described above — you run it in your own AI assistant.

Ready-made resources

Reusable prompts and templates for the exact steps in this guide.

Take it further

When this task is one step inside a larger workflow or build.

FAQ

Where does simple RBAC stop being enough?

When the conditions outnumber the roles. If most cells in your matrix are a plain allow or deny, and a handful say "own records only," then roles plus a few named conditions is the right model and adding machinery would cost you more than it buys. The signal to watch is role count climbing for reasons that aren't jobs — one per team, one per region, one per project. That's a condition being encoded as a role, and it compounds: every new team means a new role, and nobody can say what any of them do by the end. The alternative, where access is decided by attributes of the request evaluated against written policy, is genuinely more expressive and genuinely more expensive — a policy language is a thing your team has to be able to read at 3am. Most systems want roles, a tenant rule, and two or three ownership conditions. Reach further when the model is telling you it can't hold the decision, not because it sounds more rigorous.

The AI produced a permission matrix. Is that a security review?

No, and treating it as one is the most expensive mistake available here. The matrix is a draft built from what you typed — it can only be as complete as your list of actors, resources and actions, and the things that hurt in access control are the ones nobody listed. The service account that runs the nightly export. The support engineer who is staff but not on this team. The internal admin tool that predates the permission system and talks to the database directly. A model can't know about any of those, so it will produce a clean matrix with a hole in exactly the shape of what you forgot. Nothing here scans code, discovers existing permissions, or tests whether the model is enforced. What it gives you is an artifact a security owner can review and argue with — and the reviewing is the part that makes it worth anything.

How is this different from reviewing the authorization in an API design?

Scope, and what's assumed. An API design review looks at one contract and asks whether its authorization is expressed correctly — whether a privileged field is accepted from any caller, what a denial returns. It takes the roles as given; they arrive in the review already named. This guide produces the thing that review assumes exists: the actors, the resources, the actions, the roles derived from real jobs, and the matrix that says which pairs are allowed. One is a check on an endpoint, the other is the model the whole system's endpoints are checked against. In practice the model comes first and the contract review catches the places the implementation drifts from it — and if you find yourself deciding what a role means during an endpoint review, that's the signal the model was never written down.

Can any of this find the access-control bug in code we already shipped?

Not from here, and it's worth being exact about the split. This guide is design-side: it helps you write down the model you meant to build. Finding where running code diverges from that model is a review of the code itself, which needs the code — and even then, what a prompt gets you is a structured reading by a model that hasn't executed anything, not a scan. Nothing in NewPrompt connects to your repository, your database, your identity provider or a policy engine; nothing runs, nothing tests, nothing enforces. So the sequence that works is: design the model, have a security owner review it, implement it where the actions actually happen rather than where the buttons are, and then have someone qualified review the implementation against the model. High-stakes systems still want a real audit of the built thing — a design artifact never substitutes for that.