F06 · Session 6 · Chapter 1
Who sees what, written once
Broken access control is the most common serious flaw in web applications. It happens when the rule about who may see and do what is written in many places and they stop agreeing. This chapter explains the cure: one matrix, written once, that everything else is generated from.
25 min3 chapters≈ 85 minutes2 exercises12-question assessment · 80% passes
By the end of this chapter you can
- Explain why access rules written in several places drift apart, and what that costs.
- Read a Role and Exposure Matrix: roles, data, exposures and scope.
- Explain least privilege and default deny in plant terms.
The number one flaw
OWASP, the open community that tracks web application security, ranks broken access control first in its Top 10: a signed-in user reaching data or actions their role should not [1]. It tops the list not because it is clever but because it is easy to get wrong. Most applications write the same rule in three places: the screen hides a button, the server checks a role, the database limits rows. Each copy is written by hand, at a different time, often by a different person or agent.
On a plant platform the gaps have names. A line 2 operator can read line 3's quality holds. A contractor who left still has a login that can export work orders. A supervisor approves their own downtime entry. An AI agent given an administrator key can change records it was only meant to read. Each is a rule that existed somewhere, but not everywhere.
Knowledge check
A screen hides the Approve button from operators, but the server route behind it does not check the role. What can an operator do?
Reading the matrix
A Role and Exposure Matrix is a table. Each row is a role: a job, not a person. Each column is a set of data (a table) or a screen. Each cell is the exposure: what that role may do with that data. A blank cell means no access at all.
| Exposure | Meaning | Example |
|---|---|---|
| read | See the rows in scope | Operator reads readings for their line |
| insert | Add new rows | Connector inserts readings |
| update | Change rows that are still open | Supervisor updates a work order's priority |
| approve | Move a record from pending to approved | Supervisor approves a downtime entry |
| (blank) | Nothing: not even that the data exists | Operator and people records |
There is no delete exposure in this matrix. Plant records are append-only: a mistake is corrected by a new entry that reverses or replaces it, so the history can be trusted and audited. If a role ever needs to remove data, that is a decision for the matrix's owner, written down with a reason.
Scope: own line, own area, whole site
Roles alone are not enough. Two operators share a role but work on different lines. Scope says which rows a role's exposure applies to, using the same hierarchy as the naming standard from Session 5: site, area, line.
Knowledge check
Two operators have the same role. One works on line 2, the other on line 3. What makes them see different rows?
Least privilege and default deny
Two principles shape every cell. Least privilege: each role gets the minimum access its job needs, and no more. NIST defines it as restricting each user or process to the privileges needed for its tasks [2]. Default deny: anything not explicitly granted is refused, so a forgotten cell fails closed, not open. OWASP's authorization guidance recommends both [3].
Roles are jobs, not people
Write supervisor, not Dana. People are assigned to roles and scopes; when someone changes job, their assignment changes and the matrix stays the same. A person with two jobs holds two roles, and gets the union of both. Avoid a role per person and avoid a catch-all admin role for daily work: administration is a separate, rarely used role with its own sign-in.
Knowledge check
Someone suggests giving every supervisor the admin role to save time. What does least privilege say?
References
- OWASP Top 10:2025 (A01 Broken Access Control). https://owasp.org/Top10/2025/
- NIST Computer Security Resource Center glossary: least privilege. https://csrc.nist.gov/glossary/term/least_privilege
- OWASP Authorization Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
F06 · Chapter 2 · From intent to matrix
Writing your matrix
The matrix comes from the intent you wrote in Session 2 and the schema from Session 3: the decision says who needs to see what, and the tables say what there is to see. This chapter writes a complete matrix for one plant problem, as a file an agent can read.
30 min5 steps1 file: access/matrix.json≈ 35 minutes hands-on
By the end of this chapter you can
- Derive the roles, data and exposures from your intent and schema.
- Add scope, separation of duties and machine accounts.
- Write the matrix as a file in the repository that both people and generators can read.
Five steps from intent to matrix
- List the roles that touch the decision. Start from the intent's who decides, then add who supplies the data and who reviews it.
- List the data: every table in your Session 3 schema, plus each screen.
- Fill each cell with the smallest exposure the job needs: read, insert, update, approve, or blank.
- Add scope to each role: line, area or site.
- Add the rules a cell cannot express: separation of duties, and the machine accounts.
Role-based access control, where rights attach to roles and people are assigned to roles, is a long-established model with a NIST standard behind it [1]. The matrix is that model written as one readable table.
A worked example
The intent from Session 2: the shift supervisor decides, every hour, which line gets the next maintenance technician, from downtime by reason and open work orders. The schema has four tables: readings, downtime_events, work_orders and quality_checks, plus a people table for assignments.
| Role | Scope | readings | downtime_events | work_orders | quality_checks | people |
|---|---|---|---|---|---|---|
| Operator | line | read | insert, read | insert, read | ||
| Supervisor | area | read | read, approve | insert, read, update | read, approve | |
| Manager | site | read | read | read | read | |
| Maintenance | area | read | read | read, update | ||
| Connector (machine) | site | insert | ||||
| Access admin | site | insert, read, update |
Read down a column to see who can reach a table; read across a row to see everything one job can do. Both views matter. IT will read columns ("who can see work orders?"); a supervisor will read their row ("what can I do?").
Knowledge check
Following the intent, should operators be able to read work orders?
Separation of duties
Some rules involve two people. The supervisor may approve downtime entries, but not one they recorded themselves, even if they also hold the operator role. NIST lists separation of duties as a standard access control (AC-5 in SP 800-53) [2]. A cell cannot express it, so the matrix carries it as a named rule that the generator turns into a database condition in Session 7.
Machine accounts are roles too
The connector from Session 5B, an AI agent, a nightly report: each acts on the platform and so needs a role in the matrix, with the narrowest rights for its one job. Never let a machine borrow a person's login, and never give it the database's master key. A service key that bypasses all row rules belongs only in server code that a person reviews, never in a connector, a browser or an agent [3].
Writing it as a file
The matrix lives in the repository as access/matrix.json, so it is versioned and reviewed, and so generators can read it. JSON is fussy about commas but every tool reads it; your agent can keep it tidy. Anything not listed is denied.
{
"version": 1,
"owner": "operations manager",
"roles": {
"operator": { "scope": "line" },
"supervisor": { "scope": "area" },
"manager": { "scope": "site" },
"maintenance": { "scope": "area" },
"connector": { "scope": "site", "machine": true },
"access_admin":{ "scope": "site" }
},
"data": {
"readings": { "operator": "read", "supervisor": "read", "manager": "read", "maintenance": "read", "connector": "insert" },
"downtime_events": { "operator": "insert read", "supervisor": "read approve", "manager": "read", "maintenance": "read" },
"work_orders": { "supervisor": "insert read update", "manager": "read", "maintenance": "read update" },
"quality_checks": { "operator": "insert read", "supervisor": "read approve", "manager": "read" },
"people": { "access_admin": "insert read update" }
},
"rules": [
{ "name": "no_self_approval", "applies_to": ["downtime_events", "quality_checks"],
"says": "approve is refused when the approver created the record" }
],
"screens": {
"/operator": ["operator"],
"/review": ["supervisor"],
"/dashboard": ["supervisor", "manager"],
"/work": ["supervisor", "maintenance"],
"/people": ["access_admin"]
}
}You need: Your intent statement (Session 2), your schema (Session 3), your repository and AI coding agent
Write the matrix for your first problem. Start on paper if it helps, then commit it as a file.
Outcome: access/matrix.json in your repository: every role, table and screen for your intent, nothing granted that the intent does not need.
Knowledge check
The connector needs to add readings. Which account should it use?
Knowledge check
Where does a rule such as 'no one approves their own entry' live?
References
- NIST: Role Based Access Control (RBAC) project. https://csrc.nist.gov/projects/role-based-access-control
- NIST SP 800-53 Rev. 5, Security and Privacy Controls (AC-5 Separation of Duties, AC-6 Least Privilege). https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final
- Supabase Docs: API keys (publishable and secret keys). https://supabase.com/docs/guides/api/api-keys
F06 · Chapter 3 · One source
Everything else is generated
A matrix that people read but code ignores is just another copy that drifts. This chapter connects it: a generator turns the matrix into database rules, route guards, menus, tests and a review sheet, and a completeness test makes sure nothing exists outside it.
20 min5 generated outputs1 completeness testevery change by pull request
By the end of this chapter you can
- Name the five things generated from the matrix and which session builds each.
- Write a completeness test: every table and every screen appears in the matrix.
- Run an access change as a reviewed pull request, with its history.
One source, five outputs
Once the matrix is a file, a small script (the generator) reads it and writes everything that depends on it. Your AI coding agent writes the generator; you review what it produces. The outputs are never edited by hand: to change one, change the matrix and run the generator again.
| Output | What it does | Built in |
|---|---|---|
| Row-level security SQL | The database refuses any row a role or scope should not reach | Session 7 |
| Route guards | Every page and API route is closed until the matrix opens it | Session 8 |
| Navigation | Each role's menu shows only the screens it may open | Sessions 10 and 11 |
| Tests | For every cell, one request that must succeed and one that must be refused | Sessions 7, 8 and 17 |
| Review sheet | A readable table of the matrix for the owner and IT | This chapter |
Nothing outside the matrix
Default deny protects you when a cell is blank. A completeness test protects you when a whole table or route is missing from the matrix. It lists every table in the database and every route in the application, and fails if any is not in the matrix. A new table added by an agent without a matrix entry fails CI on the same pull request.
// test/matrix-complete.test.mjs — nothing exists outside the matrix.
import { readFileSync, readdirSync } from "node:fs";
import assert from "node:assert/strict";
const matrix = JSON.parse(readFileSync("access/matrix.json", "utf8"));
const roles = Object.keys(matrix.roles);
// Every exposure names a known role and a known action.
for (const [table, cells] of Object.entries(matrix.data))
for (const [role, actions] of Object.entries(cells)) {
assert.ok(roles.includes(role), `${table}: unknown role ${role}`);
for (const a of actions.split(" ")) assert.ok(["read", "insert", "update", "approve"].includes(a), `${table}.${role}: unknown action ${a}`);
}
// Every table created by a migration is in the matrix.
const sql = readdirSync("supabase/migrations").map((f) => readFileSync(`supabase/migrations/${f}`, "utf8")).join("\n");
for (const [, table] of sql.matchAll(/create table (?:if not exists )?(?:public\.)?(\w+)/gi))
assert.ok(matrix.data[table], `table ${table} is not in access/matrix.json`);
console.log("every table is in the matrix");Folder names such as supabase/migrations follow the Supabase command-line tool's layout [2]; your agent will adjust them to your repository. Session 8 adds the same check for routes.
Knowledge check
An agent adds a new table, shift_notes, and forgets the matrix. What happens?
The review sheet
The owner of the matrix (usually the operations manager) and IT both need to read it without reading JSON. The generator writes a review sheet: one table per role, listing each data set and exposure in plain words, plus the rules. It is regenerated on every change, so the sheet the owner signs is always the one that runs.
Changing access
Access changes are ordinary pull requests. The change to access/matrix.json is one line; the generated SQL and menu change with it; the tests prove the new cell; the owner approves. Months later, "who gave maintenance the right to update work orders, and why?" is answered by the repository's history in seconds.
People join and leave
Assigning a person to a role and a scope is data, not a change to the matrix: the access admin adds the assignment in the people table. When someone leaves, removing the assignment removes every right at once, and their personal login is disabled the same day. Review the assignments quarterly with each area owner [3].
You need: Your repository with access/matrix.json, your AI coding agent, GitHub
Your agent writes the code; you check that the output matches what you meant.
Outcome: A generated access review sheet that is always current, and a CI check that refuses any table outside the matrix.
Knowledge check
A manager asks IT for an export of who can read work orders. Where does the answer come from?
References
- OWASP Authorization Cheat Sheet (enforce on the server, deny by default). https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
- Supabase Docs: Local development with the Supabase CLI (migrations). https://supabase.com/docs/guides/local-development
- NIST SP 800-53 Rev. 5 (AC-2 Account Management). https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final
Chapter 4 · 12 questions · 80% passes
Final assessment
Twelve questions across the element. Score 80% (10 of 12) to pass. Your LMS records your score and each answer; you can review the chapters and try again.
15 min12 questions≈ 15 minutesRetake allowed
Your result
CivOps AI Academy
The Role and Exposure Matrix: Who Sees What and Who Does What, Written Once
Element F06 complete · Learner
Your LMS records this completion. For the CivOps Foundation certificate, finish the Foundation Course at https://civops.io/learn.