Define which operation the user is allowed to perform
Start with roles and tenants that are legitimately allowed to do different things. Reading, updating, deleting, exporting and approving an object can require different permissions. Check alternate identifiers, bulk operations and secondary interfaces that reach the same data.
ASVS provides verification requirements, and the API Security Top 10 describes recurring risks. The application still needs its own business rules. A test has to know who should be allowed to perform the operation before it can determine whether the server enforced the right decision.
Follow a cross-tenant export into the worker
In a fictional test, tenants A and B hold synthetic records. A tester acting for B requests an export belonging to A. The expected result is a denial with no protected data and no unauthorized background effect.
Check the queue, generated files and download paths. If the worker started before the denial was returned, the response alone would make the test look successful. Also try removing membership while an authorized request is waiting in the queue. That checks whether the later operation uses the access decision required by the workflow.
Record enough to check the actual effect
Keep the request identifier, account state, response and downstream observations. Check errors for protected content as well. Test the API directly where appropriate: hiding an operation in the client does not establish that the server will reject it.
For resource-use and rate-limit tests, agree on the authorized scope, bounded load, expected throttling and stopping conditions. Include legitimate batch work and accessibility requirements. A restriction needs to preserve the supported workflow as well as interrupt the prohibited one.
Retest the related operations after the fix
If the correction changes a shared authorization helper, examine the other operations that use it. Keep regression cases at the decision boundary, with synthetic data rather than customer records. Record the tested build and the workflow limits that remain so the result is not extended to paths that were never checked.
Check authority when the job runs and when the file is retrieved
Alice first queues an export, then loses membership. The flawed worker uses the old approval and creates the export. The corrected worker checks current authority and creates no artifact. Another case completes the export first, revokes the credential and then tests the download.
This model requires the initiating user to remain authorized when the job executes, and only the original requester may download its result. A service-owned job or group-shared export needs its own contract and tests. Also, stopping a future download cannot retrieve data that was already delivered.
Copy and run the complete exercise
Save the code as authorization_lab.py and run python authorization_lab.py --evidence evidence using Python 3.10 or later. In the recorded run, the deliberately flawed policy passes 11 of 25 scenarios and has 14 expected failures. The corrected policy passes 25 of 25. Exit status zero means the exercise produced those expected results; it does not mean the flawed policy is secure.
The optional JSON output records each outcome and the code hash. Tests call the request, worker and download paths, using a test-only observer to inspect effects rather than calling policy functions directly. Successful reads, writes, archives and exports are included, so a policy that denies everything will not pass.
Inspect the complete Python example
"""Synthetic, in-memory authorization study. Python 3.10+, standard library only.
No network/server/real credentials. Provisioning, clock, and observer are test-only.
Run: python authorization_lab.py --evidence evidence
"""
import argparse
import hashlib
import json
import platform
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
GRANTS = {"reader": {"read", "export"}, "editor": {"read", "write", "export"},
"admin": {"read", "write", "export", "archive"}}
DENIED = {"status": 403, "body": {"error": "not authorized"}}
def vulnerable(s, token, action, obj):
if not token or not obj or s.now >= token["expires"]:
return False
return action == "archive" or action in GRANTS.get(token["issued_role"], set())
def corrected(s, token, action, obj):
if not token or not obj or not token["active"] or s.now >= token["expires"]:
return False
if token["tenant"] != obj["tenant"]:
return False
role = s.members.get((token["tenant"], token["subject"]))
return action in GRANTS.get(role, set()) and (role == "admin" or token["subject"] in obj["grants"])
class Service:
def __init__(self, policy):
self.policy, self.now = policy, 0
self.members = {("A", "alice"): "reader", ("A", "alex"): "editor",
("A", "admin_a"): "admin", ("B", "bea"): "editor", ("B", "admin_b"): "admin"}
self.tokens = {u: {"subject": u, "tenant": t, "issued_role": r, "active": True, "expires": 10}
for (t, u), r in self.members.items()}
self.objects = {"a-shared": {"tenant": "A", "grants": {"alice", "alex"}, "text": "alpha", "archived": False},
"a-private": {"tenant": "A", "grants": {"alex"}, "text": "private alpha", "archived": False},
"b-shared": {"tenant": "B", "grants": {"bea"}, "text": "beta", "archived": False}}
self.jobs, self.artifacts = {}, {}
def request(self, actor, action, target):
token, obj = self.tokens.get(actor), self.objects.get(target)
if not self.policy(self, token, action, obj):
return deepcopy(DENIED)
if action == "read":
return {"status": 200, "body": {"text": obj["text"], "archived": obj["archived"]}}
if action == "export":
job = f"job-{len(self.jobs) + 1}"
self.jobs[job] = {"actor": actor, "target": target, "state": "queued"}
return {"status": 202, "body": {"job": job}}
if action == "write":
obj["text"] = "updated synthetic record"
elif action == "archive":
obj["archived"] = True
else:
return deepcopy(DENIED)
return {"status": 204, "body": {}}
def execute(self, job_id):
job = self.jobs[job_id] # Trusted worker entry, supplied an existing queued job.
token, obj = self.tokens[job["actor"]], self.objects[job["target"]]
if job["state"] != "queued":
return deepcopy(DENIED)
if self.policy is corrected and not self.policy(self, token, "export", obj):
job["state"] = "denied"
return deepcopy(DENIED)
self.artifacts[job_id], job["state"] = obj["text"], "ready"
return {"status": 200, "body": {"state": "ready"}}
def download(self, actor, job_id):
job, token = self.jobs.get(job_id), self.tokens.get(actor)
if not job or job_id not in self.artifacts:
return deepcopy(DENIED)
if not self.policy(self, token, "read", self.objects[job["target"]]):
return deepcopy(DENIED)
if self.policy is corrected and actor != job["actor"]:
return deepcopy(DENIED)
return {"status": 200, "body": {"text": self.artifacts[job_id]}}
def change(self, event): # Trusted fixture control, not an API endpoint.
if event == "revoke": self.tokens["alice"]["active"] = False
if event == "revoke_editor": self.tokens["alex"]["active"] = False
if event == "remove": self.members.pop(("A", "alice"))
if event == "downgrade": self.members[("A", "alex")] = "reader"
if event == "expire": self.now = 10
if event == "before_expiry": self.now = 9
def observe(self): # Test-only observer of effects, not policy internals.
return deepcopy({"objects": self.objects, "jobs": self.jobs, "artifacts": self.artifacts})
def check(condition, detail):
if not condition: raise AssertionError(detail)
# name, caller, operation, object, expected status, preceding fixture event
CASES = [
("reader_read", "alice", "read", "a-shared", 200, ""),
("editor_write", "alex", "write", "a-shared", 204, ""),
("admin_archive", "admin_a", "archive", "a-private", 204, ""),
("cross_tenant_read", "bea", "read", "a-shared", 403, ""),
("cross_tenant_write", "bea", "write", "a-shared", 403, ""),
("cross_tenant_admin", "admin_b", "archive", "a-shared", 403, ""),
("cross_tenant_export", "bea", "export", "a-shared", 403, ""),
("same_tenant_private", "alice", "read", "a-private", 403, ""),
("reader_write", "alice", "write", "a-shared", 403, ""),
("reader_archive", "alice", "archive", "a-shared", 403, ""),
("revoked_write", "alex", "write", "a-shared", 403, "revoke_editor"),
("removed_membership", "alice", "read", "a-shared", 403, "remove"),
("downgraded_write", "alex", "write", "a-shared", 403, "downgrade"),
("downgraded_read", "alex", "read", "a-shared", 200, "downgrade"),
("unknown_credential", "unknown", "read", "a-shared", 403, ""),
("unknown_action", "admin_a", "publish", "a-shared", 403, ""),
("before_expiry", "alice", "read", "a-shared", 200, "before_expiry"),
("at_expiry", "alice", "read", "a-shared", 403, "expire"),
("missing_object", "alice", "read", "missing", 403, ""),
]
FLOWS = [("export_success", "", "worker", "alice"), ("worker_removed", "remove", "worker", "alice"),
("worker_revoked", "revoke", "worker", "alice"), ("worker_expired", "expire", "worker", "alice"),
("download_revoked", "revoke", "download", "alice"), ("download_other_subject", "", "download", "alex")]
EXPECTED_FAILURES = {"cross_tenant_read", "cross_tenant_write", "cross_tenant_admin", "cross_tenant_export",
"same_tenant_private", "reader_archive", "revoked_write", "removed_membership", "downgraded_write",
"worker_removed", "worker_revoked", "worker_expired", "download_revoked", "download_other_subject"}
def scenario(policy, case):
name, actor, action, target, want, event = case
s = Service(policy); s.change(event)
before = s.observe(); response = s.request(actor, action, target)
check(response["status"] == want, f"expected {want}, received {response}")
if want == 403:
check(response == DENIED and s.observe() == before, "denial leaked data or changed protected state")
elif action == "read": check(response["body"] == {"text": "alpha", "archived": False}, "incorrect read")
elif action == "write": check(s.request("alice", "read", target)["body"]["text"] == "updated synthetic record", "write missing")
elif action == "archive": check(s.request("alex", "read", target)["body"]["archived"], "archive missing")
def flow(policy, case):
name, event, phase, downloader = case
s = Service(policy); queued = s.request("alice", "export", "a-shared")
check(queued["status"] == 202, "authorized enqueue failed")
job = queued["body"]["job"]
if phase == "download": check(s.execute(job)["status"] == 200, "authorized export failed")
s.change(event)
if phase == "worker" and event:
check(s.execute(job) == DENIED, "worker retained revoked or expired authority")
state = s.observe()
check(state["jobs"][job]["state"] == "denied" and job not in state["artifacts"], "denied worker produced output")
elif phase == "download": check(s.download(downloader, job) == DENIED, "download crossed authority boundary")
else:
check(s.execute(job)["status"] == 200, "authorized worker failed")
check(s.download("alice", job) == {"status": 200, "body": {"text": "alpha"}}, "authorized download failed")
def run(policy):
rows = []
for test, cases in [(scenario, CASES), (flow, FLOWS)]:
for case in cases:
try: test(policy, case); rows.append({"scenario": case[0], "passed": True})
except AssertionError as error: rows.append({"scenario": case[0], "passed": False, "detail": str(error)})
return rows
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--evidence", type=Path)
args = parser.parse_args()
results = {p.__name__: run(p) for p in [vulnerable, corrected]}
failures = {key: {r["scenario"] for r in rows if not r["passed"]} for key, rows in results.items()}
verified = failures["vulnerable"] == EXPECTED_FAILURES and not failures["corrected"]
for key, rows in results.items(): print(f"{key}: {len(rows)-len(failures[key])}/{len(rows)} pass; {len(failures[key])} failures")
print("Demonstration verified." if verified else "Unexpected outcome; inspect evidence.")
if args.evidence:
args.evidence.mkdir(parents=True, exist_ok=True)
receipt = {"run_at_utc": datetime.now(timezone.utc).isoformat(), "python": platform.python_version(),
"code_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), "network_used": False,
"data": "synthetic", "demonstration_verified": verified, "results": results}
(args.evidence / "run.json").write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8")
return 0 if verified else 1
if __name__ == "__main__": raise SystemExit(main())
Read the results as observations from this model
The table records a local synthetic run, not an assessment of a live application. The expected failures show which cases the deliberately flawed policy mishandles. The corrected policy is required to handle those cases while continuing to allow legitimate work.
| Scenario | Deliberately flawed model | Corrected model |
|---|---|---|
| reader read | Pass | Pass |
| editor write | Pass | Pass |
| admin archive | Pass | Pass |
| cross tenant read | Expected failure caught | Pass |
| cross tenant write | Expected failure caught | Pass |
| cross tenant admin | Expected failure caught | Pass |
| cross tenant export | Expected failure caught | Pass |
| same tenant private | Expected failure caught | Pass |
| reader write | Pass | Pass |
| reader archive | Expected failure caught | Pass |
| revoked write | Expected failure caught | Pass |
| removed membership | Expected failure caught | Pass |
| downgraded write | Expected failure caught | Pass |
| downgraded read | Pass | Pass |
| unknown credential | Pass | Pass |
| unknown action | Pass | Pass |
| before expiry | Pass | Pass |
| at expiry | Pass | Pass |
| missing object | Pass | Pass |
| export success | Pass | Pass |
| worker removed | Expected failure caught | Pass |
| worker revoked | Expected failure caught | Pass |
| worker expired | Expected failure caught | Pass |
| download revoked | Expected failure caught | Pass |
| download other subject | Expected failure caught | Pass |
Measure the behavior of a real deployment separately
In this model, changes take effect at the next sequential decision and expiry occurs at the specified tick. A deployed system may have caches, refresh credentials, worker delays and delivery paths that change that timing. Measure those effects. Keep credential revocation, membership removal and role downgrade separate: the downgraded editor may still be allowed to read even though writing must stop.
The exercise does not implement signature validation, authentication, browser behavior, recovery, persistence or concurrency. In a database-backed application, permission might change between a check and a write. The system needs a consistency decision for that case. Passing these scenarios does not make the model a complete production authorization system or establish a general revocation guarantee.
Sources and reference editions
- OWASP Application Security Verification Standard5.0.0
- OWASP API Security Project2023
- OWASP Top 10 application security program guidance2025
- API1:2023 Broken Object Level AuthorizationOWASP API Security 2023
- API5:2023 Broken Function Level AuthorizationOWASP API Security 2023
- Authorization Cheat SheetRetrieved October 3, 2026
- Session Management Cheat SheetRetrieved October 3, 2026