Authorisation

Authentication says who you are; authorisation decides what you may touch, and that check belongs in the query.

Syntaxpool.query('SELECT * FROM documents WHERE id = $1 AND team_id = $2', [id, user.teamId])

Authentication and authorisation are different jobs. Authentication established that this request is from user 42. Authorisation answers the next question: may user 42 read this document?

Roles versus permissions

A role is a label on a user: viewer, editor, admin. A permission is a single named action: documents:read, invoices:refund. Checking roles in route code looks tidy at first and rots quickly, because every new rule reads role === 'admin' || role === 'editor' and the list grows in a dozen places. Map roles to permissions once, then check permissions. Adding a role becomes an entry in that map instead of a search through the codebase.

Check where the data lives

A permission says what kind of thing you may do. It says nothing about which rows. Those are two separate checks, and the second one belongs in the database query, not in an if after it.

Put the owner or tenant column in the WHERE clause: WHERE id = $1 AND team_id = $2. Now there is no moment where the wrong row sits in a variable waiting for someone to forget the guard. A missing row returns 404, which also avoids telling a stranger that a document with that ID exists.

Deny by default

Every route needs an explicit answer. A route with no check should fail, not pass.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Keeping one customer's records invisible to another in a multi-tenant API.
  • Introducing a second role to an app that has only had admins.
  • Closing an insecure-direct-object-reference report where changing an ID in the URL returned someone else's data.
  • Deciding between 403 and 404 for a resource the caller may not know exists.

More examples

One role-to-permission map, and a guard built from it

Worth doing on the day you add a second role. The map keeps role names out of the routes, so a question like "what can an editor do?" has one answer in one file rather than being spread across every handler. The two route registrations are the same code; the factory form is what you will see in most codebases, and recognising that it returns a middleware is the whole trick.

Example · javascript
// The only place a role name appears. Adding a role is an entry here.
const ROLE_PERMISSIONS = {
  viewer: ['documents:read'],
  editor: ['documents:read', 'documents:write'],
  admin:  ['documents:read', 'documents:write', 'users:manage'],
};

function permissionsFor(role) {
  return new Set(ROLE_PERMISSIONS[role] ?? []); // unknown role gets nothing
}

// LONGHAND: the check written inline in the route.
app.post('/documents', (req, res, next) => {
  if (!permissionsFor(req.user.role).has('documents:write')) {
    return res.status(403).json({ error: 'forbidden' });
  }
  next();
}, createDocument);

// SHORTHAND: the same check as a named middleware factory. requirePermission
// RETURNS the function above, so these two routes behave identically.
function requirePermission(permission) {
  return (req, res, next) => {
    if (!permissionsFor(req.user.role).has(permission)) {
      return res.status(403).json({ error: 'forbidden' });
    }
    next();
  };
}

app.post('/documents', requirePermission('documents:write'), createDocument);

The ownership check in the query, and the version that leaks

The difference between a secure endpoint and an insecure one, in one line of SQL. The broken version is not a hypothetical: it passes every test written by someone logged in as the owner, and it is the shape behind most insecure-direct-object-reference reports. Make the tenant column part of every lookup and the bug becomes impossible to write.

Example · javascript
// BROKEN. The role was checked, the row was not. Any editor can read
// any team's document by changing the number in the URL.
app.get('/documents/:id', requirePermission('documents:read'), async (req, res) => {
  const { rows } = await pool.query('SELECT * FROM documents WHERE id = $1', [req.params.id]);
  if (rows.length === 0) return res.status(404).json({ error: 'not found' });
  res.json(rows[0]);
});

// CORRECT. team_id comes from the authenticated session, never from the
// request, and it is part of the lookup rather than a check afterwards.
app.get('/documents/:id', requirePermission('documents:read'), async (req, res) => {
  const { rows } = await pool.query(
    'SELECT * FROM documents WHERE id = $1 AND team_id = $2',
    [req.params.id, req.user.teamId],
  );
  // 404 rather than 403: a stranger learns nothing about what exists.
  if (rows.length === 0) return res.status(404).json({ error: 'not found' });
  res.json(rows[0]);
});

A nested resource, checked in one query

Use this once resources nest more than one level deep, which is where read-then-check logic usually breaks down. Folding the parent's team into the DELETE makes the authorisation and the write a single atomic statement, so nothing can change between the check and the change.

Example · javascript
// A comment belongs to a document, and the document belongs to a team.
// Fetching the comment and then checking its parent needs two round trips
// and leaves a gap where the wrong row is already in hand.
//
// One join closes it: the comment is only found if the document it hangs
// off belongs to the caller's team.
async function deleteComment(user, commentId) {
  const { rowCount } = await pool.query(
    `DELETE FROM comments c
      USING documents d
      WHERE c.id = $1
        AND c.document_id = d.id
        AND d.team_id = $2`,
    [commentId, user.teamId],
  );
  // rowCount of 0 covers both "no such comment" and "not yours", and the
  // caller cannot tell which, so the same 404 is the honest answer.
  return rowCount === 1;
}

Discussion

  • Be the first to comment on this lesson.