[Explainer]

Where access control hides in business logic

Some access rules are not middleware or query scope: they are encoded in the feature logic itself. What that class looks like, why a per-line read does not see it, and how to read it.

Aevral,

Two of the access-control classes have clean names. IDOR is the caller naming the record: the object-level failure (OWASP API1:2023, Broken Object Level Authorization). The under-guarded route is the gate failure (OWASP API5:2023, Broken Function Level Authorization). The third class Aevral reads has no single-line signature: business-logic access control, where the rule exists and the code enforcing it is correct application code, and the flaw is that the rule it encodes is not the rule the product promises. This page is what that class means, and why it survives every scanner that reads one file at a time.

The third class

Two failures have a shape you can point at in the diff. Object-level: an id the caller supplies reaches a query with no scope clause. Gate-level: a route mounts with no role check in front of it. Both are named classes (CWE-639, Authorization Bypass Through User-Controlled Key, for the first; the broader CWE-284 category, Improper Access Control, with the API5:2023 entry for the second).

Business-logic access control is the third shape: the access rule is enforced by product logic, inside the feature it guards. Who may export data is a plan-tier question. Who may see invoices may be a billing-role question. Who may invite members is a seat-allowance question. None of those is a middleware file; the rule is scattered across pricing configuration, entitlement tables, and the handler that consumes them. The check on the line passes; the question is whether the line encodes the rule the product actually promises.

Why the line looks correct

A business-logic access flaw is invisible to a per-line read because every line is individually correct code. The string comparison works. The flag is fetched. The list membership is tested properly. What is wrong is a fact about the system: the plan-name list hardcodes what the product promises, the role label is a display string, the archived tenant still has an active-looking flag. The flaw is the assumption sitting behind the code, and assumptions are not on any line.

This is also why the class is a reading job and not a rules job. A rule engine can test whether a gate exists on a route; it cannot know that your product promises trial organizations cannot bulk-export, or that the `contractor` role was supposed to stay out of invoice routes. Those facts live in the product's own vocabulary, which is exactly where the cross-file reading has to go.

The shape, in one screen of code

An export endpoint in an Express service, gated by plan tier. Three facts meet here: the session resolves the caller and the org, the plan tier drives the feature, and the check reads a plan-name list. The code is idiomatic and the tests pass, because the test org is on a normal plan.

export route, before and after the entitlements lookup
  // requireSession resolves the caller; the session carries orgId.
  app.get("/orgs/:id/export", requireSession, async (req, res) => {
-    if (!["pro", "business"].includes(req.org.planName)) {
-      return res.status(403).json({ error: "plan_required" });
-    }
+    const entitlement = await db.entitlement.findFirst({
+      where: { orgId: req.session.orgId, feature: "bulk_export" },
+    });
+    if (!entitlement) return res.status(403).json({ error: "not_entitled" });
     await streamExport(res, req.session.orgId);
  });

What the read answers

The before-version asks what plan the organization is on, and answers with a string. The string comes from the org row, so it is session-derived, but the rule it encodes is fragile in ways the line cannot show: trial orgs provisioned as `pro-trial` pass the `includes` check and receive the admin feature; a plan renamed in the pricing config silently exits the gate; a second code path that greps for 'pro' catches plans the list never meant to include. The access rule exists, and no single line holds it.

The after-version moves the question to an entitlement record, the place where the product actually decides what an organization has bought. The denial becomes explicit and auditable: a human can look at one table row and see why the request was refused. The before-version is not a syntax error or a missing middleware; it is a rule that drifted from the promise, which is the shape this class takes.

Shapes worth stopping for

A privilege value the caller supplies. A plan, tier, or role arriving in the request body or query, trusted by the handler. The session already resolved who the caller is; the request should not be allowed to upgrade them.

A label-matched role. An authorization decision made by comparing a free-text role label, where a rename in the admin UI or a localized label becomes an access decision. Roles that decide access should be stable identifiers, and the check should be against the identifier, not the display string.

A state machine skipped. Invitation pending but the member already listed; subscription cancelled but the workspace flag still active; a tenant archived but its cron jobs still serving reads. The access rule depends on lifecycle state, and the diff that touches state rarely updates the gate.

A limit trusted from the client. Seat counts, usage caps, and rate tiers computed from values the client sends. The allowance is a business fact; when the client names it, the caller names the rule.

A reading job, closed by a human

The rule lives across the pricing config, the entitlement table, the handler, and the tests, and none of those files is wrong on its own. That is what makes this class a reading job: the unit of meaning is the relation between what the product promises and what the check encodes, and relations move when a plan is renamed or a feature is re-gated. It is the cross-file reading the whole-repo scan encodes over the default-branch snapshot, and the reading Aevral's PR security review, live on install, applies to pull requests, as findings that are leads: the file, the lines, the reason a human should look, and a fix prompt for the coding agent.

A finding stays a lead. A human reads the evidence and decides whether the encoded rule matches the promised one; the agent adjusts the gate and a human reviews before merge. Nothing in the loop claims a catch rate or patches on its own. The durable verification here, as with the object-level class, is the probe written into the test suite: the trial org that expects the 403 is the test that keeps the gate from drifting the next time the pricing config is edited.

Sources

CWE-284: Improper Access Control; OWASP API5:2023 Broken Function Level Authorization; OWASP Authorization Cheat Sheet.

Read next

What a whole-repo authorization scan reads; How IDOR happens in multi-tenant code; Reviewing a pull request for access control.

More guides


Catch security flaws before you merge.

Install the GitHub App and PR review starts on. Sign in with GitHub to connect it, then press Scan for the repository you already have.