Skip to content

Audit access control in an open-source multi-tenant app

  • Case study 1
  • 120 min
  • Optional · 1 case study needed for the certificate
Case study 1 of 2 · Authentication, Permissions and Multi-Tenant ApplicationsAudit access control in an open-source multi-tenant app

What you will be able to do

Take a real, open-source, multi-tenant application that you did not write, run it on your own computer, and check whether it keeps two organizations apart. You will map its users, organizations and roles onto the course’s model, write its access matrix, and test every cell with a small matrix of HTTP requests, using the same thinking as the rest of this course. The result is a short, honest report and a project you can show.

Case studyPortfolio piece

Licence
MIT
Time
About 2 hours
Runs in
VS Code and Docker, Python 3.12+
Skills used
  • Mapping a tenant model
  • Writing an access matrix
  • Object-level access (BOLA) tests
  • Role and scope tests
  • Reading an audit log
  • A parametrised test harness (pytest + httpx)

Unzip the starter, open the folder in VS Code, and follow the steps below. They start with a new virtual environment.

The starter project has the plumbing: a Docker file that runs Gitea, a script that fills it with made-up people and records, and the shape of the test harness. You write the access matrix and the tests. The finished project is the full result, for when you are stuck or want to compare. Its code is yours to use and publish.

Before you startWhat to know and to have readyAbout 120 minutes

What you need to know

This case study uses the whole course, applied to someone else’s code:

You do not need to know the Go language. You read the app’s behaviour through its HTTP API, not its source.

What you need on your computer

  • Docker Desktop (running), VS Code, and Python 3.12 or newer.
  • About 1 GB of free disk and memory: the Gitea image is about 70 MB, and the running container uses about 120 MB.

Tested on macOS (Apple silicon) with Docker Engine 29.6.1. Gitea is pinned to version 28.1.0 by its image digest, so you test the exact code we tested. The Windows (PowerShell) commands follow the course’s pattern but were not run. Your timings will differ.

Priya Shah runs a small platform team. Her company already uses the course’s help desk, built in the earlier modules. Now the team wants a place to keep its own code and track its own work, and someone suggests Gitea, a free, open-source service much like a small GitHub. It is multi-tenant in the same sense as the help desk: it has organizations, each with its own private repositories, members and roles.

Before the team trusts Gitea with two clients’ code in two organizations, Priya asks the question this course keeps asking: does it actually keep them apart? She does not want a sales page’s promise. She wants to see the real answers.

So she asks Amira to audit it. Not to break into anyone’s server — that would be both wrong and useless — but to run Gitea on her own laptop, make two make-believe organizations with make-believe people, and try every crossing the course warned about: a guessed ID from the other organization, a small role reaching for a big action, the browser naming its own organization, a file link, an export, and the audit log. Whatever Gitea does, she records the real response.

Kwame frames the job on the whiteboard: “An audit is three questions asked of every door. Who is knocking? Which building are they in? What are they allowed to do there? We are not here to admire the lock. We are here to try the handle.”

The people and the two organizations are made up. Gitea is real and open source.

You will build gitea-access-audit: a small project that

  1. runs Gitea in Docker, pinned to one version;
  2. seeds it, through its API, with the course’s two organizations (Larkfield and Bramble Books), seven people with the course’s roles, a private repository each, and some issues, comments and files;
  3. states the access matrix for Gitea in code — who may do what, in which organization, and the status each request must get; and
  4. tests every cell with a parametrised harness (pytest + httpx), then writes a short report with the real responses.

The deliverable is the report, the passing tests, and the honest list at the end: what Gitea does well, and what this course would change.

This is an audit of access control, through the HTTP API, on the routes we list. It is not a full security audit of Gitea. A complete audit would also look at cross-references between repositories, search, webhooks, notifications, packages, the web interface and the Git protocol. We say so in the report, and we never claim “Gitea passed every test”.

The app is Gitea, an open-source Git service. We chose it over other candidates (an analytics tool, a low-code platform, a chat service) for three reasons: its licence is cleanly permissive, it is small enough to run in one container, and its model matches this course almost one for one.

  • Licence: MIT. The LICENSE file says MIT, and every source file carries an SPDX-License-Identifier: MIT header. MIT lets you run, change and publish. There is no non-commercial clause and no separate “enterprise” licence in the core. (Of the other candidates, two reported an unclear licence on their code page, which is a reason to stop: an audit that you cannot share freely is worth less.)
  • Version: 28.1.0, released a few days before this was written. The project’s compose.yaml pins the Docker image by its digest (sha256:f505a0d3…a21a), so a newer upload cannot silently change what you test. A newer Gitea may answer differently; re-pin and re-run before you trust an old result.

The dataset.md card in the project records the source, the licence, the version and the image digest.

An audit starts by learning the app’s own words for the course’s ideas. Gitea’s words are close:

Two columns. On the left, the course's terms; on the right, Gitea's. tenant / organization maps to an organization (larkfield, bramble). membership maps to a membership through a team (Camille is in two). role owner maps to the Owners team (Grace, Ines). role staff maps to a team with write permission (Sam, Camille). role read_only maps to a team with read permission (Omar, Camille). platform administrator maps to a site administrator (Kwame). a tenant-scoped record maps to an issue, comment or file in a private repository.
The course's model (left) maps cleanly onto Gitea (right). A role lives on a team, not on a person, so Camille can be staff in one organization and read-only in another.
Text description of the diagram

On the left are the course’s terms: tenant, membership, the roles owner, staff and read_only, the platform administrator, and a tenant-scoped record. On the right are Gitea’s: an organization, a membership through a team, the Owners team, a team with write permission, a team with read permission, a site administrator, and an issue, comment or file in a private repository. Each course term points to its Gitea equivalent. The example people are Grace and Ines (owners), Sam and Camille (staff), Omar (read), Kwame (site administrator), and Camille again, who belongs to two organizations with different roles.

A role in Gitea lives on a team, and a team belongs to one organization. A person joins a team, so a person’s powers come from the team, not from the person. This is the course’s rule — a role belongs to a membership, not to a person — in Gitea’s own shape. It is why Camille can be staff at Larkfield and read-only at Bramble Books at the same time.

One word needs care. Gitea’s read team is not the course’s read_only. As you will see, Gitea’s read role may still open issues and add comments. “Read” there means “read the code”; it is not “may change nothing”. An audit writes down the app’s real behaviour, not the behaviour its labels suggest.

  1. Download the starter project from the box at the top, unzip it, and open the folder gitea-access-audit in VS Code. Open a terminal (Terminal > New Terminal).

  2. Start Gitea. A unique project name and port keep it clear of anything else:

    Terminal window
    export COMPOSE_PROJECT_NAME=gitea-audit GITEA_PORT=3300
    docker compose up -d --wait

    Open http://localhost:3300 in your browser. You see Gitea’s sign-in page. There is no sign-up link: the settings in compose.yaml lock the install, turn registration off, and require sign-in to see anything.

  3. Make a virtual environment and install the two packages (httpx to make requests, pytest to run the tests):

    Terminal window
    python3 -m venv .venv
    source .venv/bin/activate
    python -m pip install -r requirements.txt
  4. Fill Gitea with the two organizations and their people:

    Terminal window
    python -m audit.seed
    Output
    Made 7 people (1 site administrator).
    Larkfield: organization 'larkfield', teams ['Owners', 'read-only', 'staff'], repository larkfield/help-desk, 2 issues, 1 comment, 1 file.
    Bramble Books: organization 'bramble', teams ['Owners', 'read-only', 'staff'], repository bramble/help-desk, 2 issues, 1 comment, 1 file.
    Wrote .audit-state.json (8 tokens; local and made-up). API: http://127.0.0.1:3300/api/v1

What this tells us. The first account can only be made on the server itself, so the script runs Gitea’s own admin user create command inside the container for Kwame, the site administrator. Everyone else is made through the API, as Kwame. Each person then gets a personal access token; the script saves the tokens in .audit-state.json, which stays on your computer and is kept out of Git. You can now sign in at the website as, for example, sam with the password practice-sam-2026 and see Larkfield, but not Bramble Books.

A token here is Gitea’s own kind: a long random string, sent as Authorization: token …. It is not a signed JWT like the help desk’s. That is the first audit note: Gitea’s tokens carry no expiry. They are valid until someone deletes them. The help desk’s access tokens expire in ten minutes; Gitea trades that safety for convenience, and an audit records the trade.

Before testing, state what should happen. The project’s audit/matrix.py lists the routes under test and, for each, the roles that may use it. The expected status then follows the course’s three questions:

  • No membership in the organization → 404. The caller learns nothing, not even that the record exists.
  • A member whose role is too small → 403.
  • Otherwise → the success status (200 for a read, 201 for a create).
audit/matrix.py (the rule)
def expected_status(role, route):
_method, _path, _opts, ok = ROUTES[route]
if role is None:
return 404 # not a member: 404 for everything
if role in ALLOWED[route]:
return ok # 200 or 201
return 403 # a member whose role is too small

The routes are scoped by organization (/orgs/{org}/…) or by repository (/repos/{org}/{repo}/…). An outsider’s request names an organization it does not belong to, so Gitea can answer 404. Writing ALLOWED is the audit’s judgement step. You decide each cell from the course’s model and from what you learn by trying Gitea. For example, you will find that Gitea’s read role may open an issue, so "open an issue" allows all three roles, while "edit an issue" and "upload a file" allow only owner and staff.

The harness is small because the matrix does the thinking. tests/test_access_matrix.py asks, for every person, every organization and every route, whether the real status matches expected_status:

tests/test_access_matrix.py (the matrix test)
MATRIX = [(p, o, r) for p in PERSONS for o in ORGS for r in ROUTES]
@pytest.mark.parametrize("person, org, route", MATRIX, ids=...)
def test_matrix(client, state, person, org, route):
role = role_of(org, person)
want = ROUTES[route][3] if person == ADMIN else expected_status(role, route)
response = send(client, person, org, route, state)
assert response.status_code == want, response.text

Run it:

Terminal window
python -m pytest
Output (end)
151 passed in 1.2s

What this tells us. 151 checks passed. 140 of them are the matrix: 7 people × 2 organizations × 10 routes. The rest are the four crossings below, written as their own tests so a failure names the exact attempt. Let us look at what each crossing actually returned, because the point of an audit is the real answer, not the green tick.

The course's three questions as four gates, each with the real answer Gitea gave. 1. Who is the caller? A token Gitea checks: no token gives 403, an invalid token gives 401. 2. A member of this organization? A team membership: Tomás, or Sam asking about bramble, gets 404 and learns nothing. 3. Does the role allow the action? read / write / admin: Omar with the read role editing an issue gets 403, Sam the staff member trying to join Owners gets 403. 4. Is the record in this organization? A global ID is still scoped: Sam opening Bramble's repository by its global ID gets 404, the other organization's comment ID gets 404. A member who may do the action gets 200 or 201.
The course's three questions, asked of Gitea, with the real answers. Each 'no' has its own status, and the checks stop there.
Text description of the diagram

A request names an organization and carries a token. Gate 1, who is the caller: with no token Gitea answers 403, with an invalid token 401. Gate 2, a member of this organization: Tomás, or Sam asking about Bramble, gets 404 and learns nothing. Gate 3, does the role allow the action: Omar, who has the read role, editing an issue gets 403; Sam, a staff member, trying to join Owners gets 403. Gate 4, is the record in this organization: a global record ID of the other organization is still scoped, so Sam opening Bramble’s repository by its numeric ID gets 404, and the other organization’s comment ID gets 404. A member who may do the action gets 200 or 201.

Crossing 1: a guessed ID from the other organization (BOLA)

Section titled “Crossing 1: a guessed ID from the other organization (BOLA)”

This is the first risk of the course: an object-level access failure. Sam is staff at Larkfield. He has never seen Bramble’s data, but IDs are often guessable. Gitea gives each repository, comment and attachment a global numeric ID, shared across the whole server, so Bramble’s repository might be number 2 and its comment number 2. Sam tries to reach them through his own, legitimate paths:

The attempt (from audit/report.py)
# Sam opens Bramble's repository by its global numeric ID
GET /repositories/2
# Sam reads Bramble's comment by its global ID, through his own repository path
GET /repos/larkfield/help-desk/issues/comments/2
# Sam downloads Bramble's attachment ID, through his own issue
GET /repos/larkfield/help-desk/issues/1/assets/2

Every one returns 404 “not found”. Gitea scopes the lookup by the caller’s access, so a correct ID of a repository Sam cannot see is “not found”, exactly as the course’s help desk answers for a ticket of the other shop. The ID being right does not help, because the server checks membership before it looks.

One subtlety the audit taught us: an issue number is per repository, not global. Issue number 1 under larkfield is Larkfield’s own issue, not Bramble’s. So “ask for the other organization’s issue through your own path” reads your own issue — it is not a crossing at all. The real crossing uses a global ID (a repository, a comment, an attachment), and those are the ones to test. Finding this is part of the audit: you learn which IDs are shared before you can probe them.

Crossing 2: a small role reaching for a big action (escalation)

Section titled “Crossing 2: a small role reaching for a big action (escalation)”

The second risk is privilege escalation. A member tries to grow their own powers:

Attempt Response
Sam (staff) adds himself to the Owners team 403 “Must be an organization owner”
Sam (staff) makes a new team with admin permission 403 “Must be an organization owner”
Omar (read) raises the read team’s permission to admin 403 “Must be an organization owner”
Camille (read-only at Bramble) joins Bramble’s Owners team 403 “Must be an organization owner”

Gitea refuses each one. Only an owner may change teams, so a staff or read member cannot promote themselves. This is the course’s rule: the power to grant power is itself the most guarded action.

There is one kind of role in Gitea that does cross organizations: the site administrator. Kwame, as site administrator, reads every organization. He can even act as another user through the API (Gitea calls it sudo): GET /user?sudo=grace returns Grace. A non-administrator who tries the same gets 403 “Only administrators allowed to sudo.” This is a real power, wider than the course’s platform administrator, who could only ask for a small, time-limited grant. The audit records it plainly: on this server, the site administrator can see and do everything. The only limit is who you make an administrator.

Crossing 3: the organization taken from the request, not the token

Section titled “Crossing 3: the organization taken from the request, not the token”

The course’s third misconception is “send the tenant ID from the browser”. Gitea does not offer the caller a way to name their organization in a header or a body; the organization is always a fixed part of the URL path (/orgs/{org}/…, /repos/{org}/{repo}/…). So there is no “X-Organization” header to forge. Gitea derives membership from its own tables for the organization in the path, and an outsider gets 404. The design removes the misconception rather than guarding against it — a good pattern, and one the course’s help desk follows too.

A file is only as private as its link. Gitea serves an attachment at /attachments/{uuid}, a long random identifier:

Who asks for Bramble’s receipt file Response
Ines (owner at Bramble) or Camille (member) 200, the file
Sam (not a member of Bramble) 404
No token at all 303, a redirect to the sign-in page

So a member is served the file, an outsider gets 404, and an anonymous request is sent to sign in. The link is checked on every request; it is not a secret that works for anyone who holds it. That matches the course’s rule: check first, then serve.

The nearest thing to the help desk’s export is the repository archive, a zip of the code at /repos/{org}/{repo}/archive/main.zip. A member of the organization gets 200 and the zip; an outsider gets 404. The archive is built on the server from the repository the caller may already read, so it adds no new access.

An audit log is the course’s “visitor book”. Gitea has one, but two facts matter, and both go in the report.

First, it is off by default. Gitea ships with [audit] RECORD_OUTPUT = disabled. The project turns it on in compose.yaml (RECORD_OUTPUT=database). An auditor must check the setting, not assume the book exists.

Second, it records changes, not refusals. After a clean seed, the log holds 33 events: the server starting, six users created, eight tokens added, two organizations, four teams, four team memberships, two repositories, four issues and two comments. Every change we made is there, each with its actor, its target and whether it came from the API, the web or the command line. The site administrator can export the whole log as one line per event:

Two lines of the exported audit log (reports/audit-export.jsonl)
{"action":"organization:create","actor":{"type":"user","name":"ines"},"scope":{"type":"organization","name":"bramble"},"origin":"api", ...}
{"action":"user:impersonation:start","actor":{"type":"user","name":"kwame"},"scope":{"type":"user","name":"grace"},"origin":"api", ...}

The second line is worth noting: Kwame acting as Grace is recorded, so the widest power leaves a trace. But run every refused request from the crossings above, and the log grows by nothing. The failed attempts — the guessed IDs, the escalations — are not written down. The help desk in this course records a denial as well as a success, so the owner can see that someone tried. Gitea’s log would not show Priya that Sam probed Bramble. That is a real gap, and it is the clearest thing this course would change.

Proof, from the report (reports/responses.json)
# after a clean seed: 33 audit events
# after four refused requests (two escalations, two cross-organization reads): still 33
Terminal window
python -m audit.report
Output
Matrix: 140/140 as expected.
Extra checks: 11/11 as expected.
Audit events: 33 (actions: ['issue:comment:create', 'issue:create', 'organization:create', ...]).
Wrote reports/REPORT.md, reports/responses.json, reports/audit-export.jsonl.

reports/REPORT.md is the short, honest summary an auditor hands over: the matrix result, the crossing-by-crossing table, and what the audit log held. responses.json keeps every status code, so a reader can check any claim.

What Gitea does well, and what this course would change

Section titled “What Gitea does well, and what this course would change”

What it does well.

  • The organization is always in the path, never taken from a header the caller controls. The whole class of “I sent my own tenant ID” attacks has nowhere to land.
  • Outsiders get 404, for repositories, issues, members and files alike. A correct, guessed ID of another organization’s record is “not found”, so IDs leak nothing.
  • Only owners change teams. A staff or read member cannot promote themselves, make an admin team, or raise a team’s permission.
  • Token scopes are enforced on top of roles: a read-only-scoped token may read but not write, even for an owner. (You test this with Grace’s second, reporting token.)
  • File links are checked on every request, so a leaked link does not work for an outsider.

What this course would change.

  • Record denials, not only changes. The audit log should show that Sam tried to read Bramble, so an owner can notice a probe. Today only successful changes are written.
  • Turn the audit log on by default, or warn loudly that it is off. An audit book that is empty because nobody opened it is the worst of both worlds.
  • Give tokens an expiry. A token that is valid until someone remembers to delete it is a long-lived secret. The help desk’s ten-minute access token limits the damage of a leak.
  • “Read” should mean read. A role called read that can open issues and comment surprises a careful administrator. The course keeps read_only strictly read-only, so the label cannot mislead.
  • Narrow the site administrator. One role that can see every organization and act as any user is a large blast radius. The course prefers a small, time-limited, audited support grant to a standing master key.

None of this means Gitea is unsafe; it means an audit turns promises into a list a team can weigh. Priya now has that list.

Common mistake

The audit 'passes' but proves nothing

A common mistake is to test only the happy path: a member reads their own organization, the test is green, the auditor declares victory. That shows the lock turns for the person who owns the key. It says nothing about the outsider. An access-control audit is mostly negative tests: the requests that must fail. A matrix is the way to be sure you ran every one. If your report has no 403s and no 404s in it, you have not audited access control yet.

  • Local and synthetic only. Every request in this project targets a Gitea container on your own computer, with made-up people and records. Running these tests against a server you do not own is an attack, not an audit, whatever you call it.
  • This is not a full security audit. It covers access control on the API routes in audit/matrix.py. A complete audit would also test cross-references between repositories, search, webhooks, notifications, packages, the web interface and the Git protocol. The report says so, and never claims Gitea “passed every test”.
  • One pinned version. The results are for Gitea 28.1.0, pinned by image digest. A newer version may behave differently. Re-pin, re-seed and re-run before trusting an old conclusion.
  • If you find a real flaw, report it responsibly. Mature projects publish a security policy and a private way to report. Do not post a working attack in public, and do not test it against anyone’s live server. The responsible path is a private report to the project, not a blog post.

This project shows an employer that you can take an unfamiliar, real codebase and reason about its access control the way a security-minded engineer does: map the model, state the matrix, test every cell, and report honestly.

What to put in the repository:

gitea-access-audit/
README.md what it is, how to run it, the model map, the limits
compose.yaml Gitea pinned by digest, with the audit log turned on
dataset.md the app, its source, licence and version
audit/ people.py, client.py, seed.py, matrix.py, report.py
tests/ the parametrised matrix, BOLA, escalation and bad-identity tests
reports/REPORT.md the audit's output (regenerated by `python -m audit.report`)
pyproject.toml, requirements.txt

To publish it:

  1. Make a new Git repository from the project folder and push it. .gitignore keeps out the virtual environment and the token file.
  2. Keep dataset.md: the app, its MIT licence, and the exact version and digest you tested.
  3. Make sure python -m pytest passes from a fresh clone against a freshly seeded Gitea, and say so in the README.
  4. Write the README for a stranger: the model map, how to run it, and — most important — the “what this course would change” list, which is the thinking an employer wants to see.

How to describe it in an interview, in two sentences: “I audited the access control of Gitea, an open-source multi-tenant Git service, by running it locally with two synthetic organizations and testing a full access matrix — every person, organization and route — plus object-level, role-escalation and bad-identity cases, with a parametrised pytest and httpx harness. I reported what it does well, such as scoping every lookup so a guessed ID of another organization returns 404, and what I would change, such as recording denied attempts in the audit log and giving tokens an expiry.”

Go further:

  • Audit a second app with the same harness, and compare. The matrix is the reusable part; only the model map and the route list change.
  • Add the web interface, not just the API, with a browser-driving tool, and check that the pages hide what the API hides.
  • Test the Git protocol, not only the HTTP API: can a non-member clone the private repository over HTTP?
  • Turn the report into a one-page scorecard for a non-technical reader, with a row per crossing and a plain verdict.

Check your understanding

Sam, staff at Larkfield, asks for Bramble's repository by its correct global ID and gets 404. A colleague says, 'So the ID must have been wrong.' Why is the colleague mistaken, and what would a 403 here have revealed?

Show the answer

The ID was right; Gitea returns 404 because Sam is not a member of Bramble, and the lookup is scoped by the caller’s access. “Not found” is the same answer Sam would get for an ID that truly does not exist, so he cannot tell the two apart — which is the point. A 403 (“you may not”) would have confirmed that the record exists and that Sam lacks permission, leaking the fact that Bramble has a repository with that ID. The course uses 404 for outsiders exactly so that a guessed ID reveals nothing.

Check your understanding

You run every refused request from the audit again, and Gitea's audit log does not grow. Name one risk this creates for an organization owner, and what the course's help desk does differently.

Show the answer

Because only successful changes are recorded, a failed attempt leaves no trace. An owner cannot see that someone probed their organization — tried a guessed ID, or tried to join the Owners team — so a reconnaissance attempt before a real breach is invisible. The course’s help desk records a denial as well as a success (with the actor, the organization and the result, but never the token), so the owner can notice that someone tried and investigate. Turning Gitea’s log on is necessary but not enough; it would still miss the denials.

Check your understanding

Gitea's 'read' team member, Omar, can open an issue and add a comment, but your access matrix for the course's help desk gives read_only none of those. Is your audit wrong? How should the report handle the difference?

Show the answer

The audit is not wrong; the two systems define the role differently, and an audit records the app’s real behaviour, not what a label suggests. The matrix for Gitea should allow “open an issue” and “comment” for the read role, because that is what Gitea does, and the tests should pass against that truth. The report then notes the difference as a finding under “what this course would change”: a role called read that can write surprises a careful administrator, and the course keeps read_only strictly read-only so the name cannot mislead. Honesty about the real behaviour, plus a clear recommendation, is the right handling.

Recap

  • An access-control audit asks the course’s three questions of every door: who is the caller, which organization, what may they do — and tries the handle rather than admiring the lock.
  • Start by mapping the app’s words onto the course’s model. In Gitea an organization is a tenant, a team carries a role, and a record lives in a private repository. Write the mapping down before you test.
  • State the access matrix in code, then test every cell with a parametrised harness. Most of the value is in the negative tests: the 403s and 404s.
  • Gitea scopes every lookup, so a guessed global ID of another organization returns 404; only owners change teams; file links are checked on every request. These are real strengths, stated from real responses.
  • It also records only changes and not denials, ships its audit log off by default, and issues tokens that never expire — findings this course would change. An honest report lists both, and never claims a clean pass.
  • The project is a portfolio piece: a reusable matrix harness, a clear model map, and a short report that turns promises into a list a team can weigh.

Next: Add sign-in and roles to an existing API