Skip to content
academia.sh

Lesson 23 / 23

Object-Level Authorization

The ownership boundary that remains after the role, scope, and tenant checks, the difference between putting the check inside the query and leaving it outside, entity information leaking from the response code, and making the ownership criterion scannable.

Contents

The tenant boundary determined which set a request can touch. Within that set, individual objects still exist. Two members of the same library carry the same role, use a token with the same scope, and belong to the same tenant; even so, one must not see the other’s loan record.

This final boundary is tested the moment an identifier in the address bar is changed. The role check passes, the scope check passes, the tenant check passes — and the request must still be denied, because the requested object does not belong to the one making the request.

Three Implementations, the Same Endpoint

The ownership check can be done in three places: never, after the record is fetched, or inside the query itself. The computation below runs all three against the same set of requests.

cat > object.mjs <<'EOF'
// object.mjs — object-level authorization: is ownership inside the query or outside it
import { DatabaseSync } from "node:sqlite";
import { randomUUID } from "node:crypto";

const db = new DatabaseSync(":memory:");
db.exec(`
  CREATE TABLE loan (loan_id TEXT PRIMARY KEY, member_id TEXT NOT NULL, isbn TEXT NOT NULL,
                      status TEXT NOT NULL, note_text TEXT);
  INSERT INTO loan VALUES
    ('O-1','U-1001','978-0201896831','OPEN','Returned with a damaged cover'),
    ('O-2','U-1001','978-0262033848','CLOSED',NULL),
    ('O-3','U-1002','978-0131103627','OPEN','Second warning sent');
`);

// Three implementations. All three answer the same request: GET /loans/{id}
const endpoints = {
  unchecked: (ctx, id) => db.prepare("SELECT * FROM loan WHERE loan_id = ?").all(id),
  fetchThenCheck: (ctx, id) => {
    const row = db.prepare("SELECT * FROM loan WHERE loan_id = ?").all(id)[0];
    if (!row) return { status: 404, body: null };
    if (row.member_id !== ctx.member) return { status: 403, body: { error: "this record is not yours" } };
    return { status: 200, body: row };
  },
  checkInQuery: (ctx, id) => {
    const row = db.prepare("SELECT * FROM loan WHERE loan_id = ? AND member_id = ?").all(id, ctx.member)[0];
    return row ? { status: 200, body: row } : { status: 404, body: null };
  },
};

const ctx = { member: "U-1001" };          // the member making the request
const pad = (s, n) => String(s).padEnd(n);

console.log("implementation    requested  result                      is someone else's record shown");
for (const id of ["O-1", "O-3", "O-9"]) {
  const raw = endpoints.unchecked(ctx, id);
  const belongsToOther = raw[0] && raw[0].member_id !== ctx.member;
  console.log(`${pad("unchecked", 17)} ${pad(id, 8)} ${pad(raw.length ? "200 record returned" : "empty", 27)} ${belongsToOther ? "YES" : "no"}`);
}
for (const [name, f] of [["fetch-then-check", endpoints.fetchThenCheck], ["check-in-query", endpoints.checkInQuery]]) {
  for (const id of ["O-1", "O-3", "O-9"]) {
    const y = f(ctx, id);
    console.log(`${pad(name, 17)} ${pad(id, 8)} ${pad(y.status + (y.body ? " record returned" : " no body"), 27)} no`);
  }
}

// Entity leakage: do the response codes of the two implementations give away someone else's record?
console.log("\ninformation that can be inferred from the response codes");
for (const [name, f] of [["fetch-then-check", endpoints.fetchThenCheck], ["check-in-query", endpoints.checkInQuery]]) {
  const existingOthers = f(ctx, "O-3").status;
  const nonexistent = f(ctx, "O-9").status;
  console.log(`  ${pad(name, 18)} someone else's record: ${existingOthers}   nonexistent record: ${nonexistent}` +
    (existingOthers !== nonexistent ? "   -> record's existence leaked" : "   -> the two are indistinguishable"));
}

// An unguessable identifier is not an authorization check.
const randomId = randomUUID();
console.log("\nunguessable identifier attempt");
console.log(`  generated identifier: ${randomId.length} characters, not guessable from a dictionary`);
console.log("  but if the identifier leaks (a record, a log, a shared link) the unchecked endpoint still returns the record.");
console.log(`  unchecked endpoint for O-3: ${endpoints.unchecked(ctx, "O-3").length} record`);

// Endpoint scan: which endpoints declare their ownership criterion?
const ENDPOINTS = [
  { path: "GET /loans/{id}",            criterion: "member_id = :subject" },
  { path: "DELETE /loans/{id}",         criterion: "member_id = :subject" },
  { path: "GET /loans/{id}/note",       criterion: null },
  { path: "GET /members/{id}/fines",    criterion: "member_id = :subject" },
  { path: "POST /loans/{id}/extend",    criterion: null },
  { path: "GET /reports/overdue",       criterion: "role = clerk" },
];
console.log("\nendpoint                        ownership criterion    finding");
let missing = 0;
for (const e of ENDPOINTS) {
  const flagged = e.criterion === null;
  if (flagged) missing++;
  console.log(`${pad(e.path, 30)} ${pad(e.criterion ?? "-", 21)} ${flagged ? "CRITERION NOT DECLARED" : "-"}`);
}
console.log(`undeclared endpoints: ${missing} / ${ENDPOINTS.length}`);
EOF
node object.mjs
implementation    requested  result                      is someone else's record shown
unchecked         O-1      200 record returned         no
unchecked         O-3      200 record returned         YES
unchecked         O-9      empty                       no
fetch-then-check  O-1      200 record returned         no
fetch-then-check  O-3      403 record returned         no
fetch-then-check  O-9      404 no body                 no
check-in-query    O-1      200 record returned         no
check-in-query    O-3      404 no body                 no
check-in-query    O-9      404 no body                 no

information that can be inferred from the response codes
  fetch-then-check   someone else's record: 403   nonexistent record: 404   -> record's existence leaked
  check-in-query     someone else's record: 404   nonexistent record: 404   -> the two are indistinguishable

unguessable identifier attempt
  generated identifier: 36 characters, not guessable from a dictionary
  but if the identifier leaks (a record, a log, a shared link) the unchecked endpoint still returns the record.
  unchecked endpoint for O-3: 1 record

endpoint                        ownership criterion    finding
GET /loans/{id}                member_id = :subject  -
DELETE /loans/{id}             member_id = :subject  -
GET /loans/{id}/note           -                     CRITERION NOT DECLARED
GET /members/{id}/fines        member_id = :subject  -
POST /loans/{id}/extend        -                     CRITERION NOT DECLARED
GET /reports/overdue           role = clerk          -
undeclared endpoints: 2 / 6

The Observable Consequence of the Unchecked Endpoint

In the first part’s table, the unchecked implementation returns someone else’s O-3 record as is. The request arrived with a valid token, the member was signed into their own account — only the path parameter changed. What leaks is a loan record; inside it sits another member’s reading history and the record’s note field.

This class of defect is silent, because the application produces no error at any layer: the log shows a successful request, the monitoring dashboard shows no change in error rate. But it is also easy to catch — if a written answer exists for the question “what is this record’s relationship to the requester” for every endpoint, the endpoints with no answer can be listed.

Where the Check Sits Changes the Response

The second part separates the two correct implementations. The version that checks after the record is fetched returns 403 for someone else’s record and 404 for a nonexistent one. Because the two differ, an observer can learn that an identifier they never tried exists: 403 says “this record exists, but it is not yours.”

In the version that checks inside the query, the two cases produce the same response. The record is either in the requester’s set or it does not exist; no distinction is made between the two. This keeps entity information from leaking, and it has an extra benefit: the check cannot be forgotten, because the criterion is part of the query. In the fetch-then-check pattern, deleting the if line is enough; making the same mistake in the check-in-query pattern requires rewriting the query itself.

The choice is not always toward 404. In cases where the user should know they lack access — a team member navigating to another team’s dashboard, for instance — 403 is more helpful, and the entity information is already shared information. The criterion is this: is the record’s existence information that should stay hidden from the requester?

An Unguessable Identifier Is Not an Authorization Check

The third part measures a common fallacy. Using an unguessable identifier instead of a sequential number makes identifiers hard to discover; it does not protect the record. Once an identifier leaks — through a record link, browser history, a server log, or a shared link — the unchecked endpoint still returns the record; the output’s last line shows this.

An unguessable identifier’s place is defense in depth, not a substitute for the check. Identifier design blocks mass discovery; the authorization check blocks a single access. The two do different jobs, and neither makes the other unnecessary.

Making the Criterion Written

The last part scans endpoints: has an ownership criterion been declared for each one? Two of the six endpoints declare no criterion, and both are sub-resources: a loan record’s note and its extension action. Sub-resources are where this defect appears most often, because the check written for the parent resource is assumed to apply to the sub-path too.

The criterion being written down does two things. When a new endpoint is added and the criterion field is left empty, the scan finds it — something a code review missed shows up in the list. Second, because the criterion is data, it can be tested: for every endpoint, a test of “call it with a subject that is not the owner and expect an empty result” can be generated automatically.

Every model built in this course — role, attribute, relationship, policy, scope, tenant — makes its decision independent of the object. Object-level authorization is that decision’s last step, and when it is skipped, all the others are bypassed. This is why authorization is not done in a single place; it is done layer by layer, along the whole path of the request.

Summary

  • Object-level authorization is the final boundary left standing after the role, scope, and tenant checks all pass: the requested record’s relationship to the requester.
  • An unchecked endpoint returns someone else’s record without producing an error; in the model, a single changed path parameter was enough.
  • When the ownership criterion is put inside the query, forgetting it becomes harder and entity information does not leak; in the fetch-then-check pattern, the 403/404 distinction gives away the record’s existence.
  • An unguessable identifier makes discovery harder; it does not authorize access. Once the identifier leaks, the unchecked endpoint still returns the record.
  • Writing down the ownership criterion for every endpoint makes it scannable and testable; in the model, both undeclared endpoints were sub-resources.

Course Wrap-Up

This course began by separating two questions: who you are and what you can do. The Authentication Flows topic took up the first question; it showed, from the basic scheme through session- and token-based flows, the detail of signature verification, the rotating use of refresh tokens, delegation protocols, and multi-factor authentication, what problem each flow solves and where it breaks. The Credential Security topic tied credential storage to measurable rules: slow-hash parameters, the separate jobs of salt and pepper, the reset flow’s surface, and the session’s lifetime. The Authorization Models topic built the second question across four models, narrowed a client’s authority with scope, carried the tenant boundary into the query, and, with this lesson, came down to the object level.

Three criteria stayed constant through the course. A decision is made in one place and enforced in another; separating the two makes auditing possible. The reason for a denial must be distinguishable, but its detail must not leak to the requester. And every check, when forgotten, must produce an observable consequence — a number of leaked records, a number of opened endpoints, a distinguishable response code.

The next course, The Data Access Layer and Business Logic, descends into the layer beneath these decisions. Moving the authorization check into the query surfaced in this course as a security requirement; there, the same query becomes a question of performance and design: how is data access separated from business logic, where is the transaction boundary drawn, and how is the cost of different paths to the same data measured?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close