DEV Community

Cover image for Enforce access control with a few custom linter rules
Guillaume Égée
Guillaume Égée

Posted on

Enforce access control with a few custom linter rules

The scariest bug in a B2B SaaS isn't a 500. It's a 200 with someone else's data in it.

I work at malibou, an HRIS that also runs payroll for its customers, built on Next.js with Clerk for authentication. Every row we serve belongs to exactly one company, and companies must never see each other. That sentence isn't hard to understand. What's hard is keeping it true on the 300th route, written by the 10th developer, on a Friday afternoon, and increasingly by a coding agent.

Access control has two halves:

Question How we handle it
Vertical (or Function Level Authorization) Is this user allowed to perform this action? A permission check
Horizontal (or Object Level Authorization) Is this user allowed to touch this specific row? A business rule

Vertical is easy, because it maps to the UI: a button either appears or it doesn't. Horizontal is trickier, because every action has to validate the specific row it touches. To read an interview, you must belong to the right organization and be the reviewer, the reviewee, or the reviewee's manager. And other entities will have other rules. No static check can derive that.

This is why Broken Object Level Authorization has been sitting at the top of the OWASP API Security Top 10 for years. It doesn't take much to open a hole: a where clause without organizationId, an id trusted straight from the query string.

The textbook fix is row-level security, and it's a good fix when the rule is "rows belong to a tenant, full stop". Ours isn't: an admin sees the whole company, a manager sees their team (a graph that changes with every promotion), an employee sees themselves plus whatever was published to them. Expressing that in SQL policies means a second implementation of the business rules, in a second language, kept in sync with the first one by hope. The TypeScript version already exists, tested and typed. We never lacked the logic. We lacked proof that the logic gets called, and that's a structural property of the code, which is exactly what a linter is for.

So we wrote our own ESLint rules. Most of them can also run on oxlint.

First rule: give every entry point one door

As Next.js gives you almost no structure on the backend, we needed to build our own rule to ensure every API route or server action handles a request the same way. Our first rule, asserts that every server entry point goes through a house middleware, permissionMiddleware. That's where the vertical check lives: the middleware calls Clerk's authfunction and early returns Unauthenticated if there's no session, Forbidden if the permission is missing.

// ✅ vertical permission check
export const GET = permissionMiddleware({
  permission: "project:read",
}).handler(async ({ request }) => {
  const projectId = request.nextUrl.searchParams.get("projectId");
  const project = await getProject({ id: projectId });

  return NextResponse.json(project);
});
Enter fullscreen mode Exit fullscreen mode

And the shape that used to ship, and now fails CI:

// ❌ no permission
export const GET = async (request: NextRequest) => {
  const projectId = request.nextUrl.searchParams.get("projectId");
  const project = await getProject({ id: projectId });

  return NextResponse.json(project);
};
Enter fullscreen mode Exit fullscreen mode

I won't detail the rule here, since it depends heavily on the shape of your project. This kind of rule is quite easy to build with an AST explorer (for instance https://astexplorer.net/) or with AI tools.

Second rule: make the tenant id impossible to forget

With the vertical shape settled, the next step is the organizational perimeter, the first of the horizontal checks. Our permission middleware injects an auth object built from the Clerk token, carrying the organizationId the user belongs to. The second rule requires that the first statement of every handler destructures the auth object, including the organization id:

.handler(async ({ ctx: { auth } }) => {
  const { organizationId } = auth;   // mandatory, first line
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Stripped to its essentials, the rule reads:

const rule = createRule({
  meta: {
    messages: {
      noAuthDestructuring:
        "The first statement of the handler must destructure the auth object, like `const { organizationId } = auth;`",
    },
  },
  create: (context) => ({
    VariableDeclarator: (node) => {
      if (!isApiRoute(node.id) && !isServerFunction(node.id)) {
        return;
      }

      const handler = getHandlerFunction(node.init);
      const [firstStatement] = handler?.body.body ?? [];
      const declarator =
        firstStatement?.type === "VariableDeclaration" ? firstStatement.declarations[0] : null;

      const isAuthDestructuring =
        declarator?.id.type === "ObjectPattern" &&
        declarator.init?.type === "Identifier" &&
        declarator.init.name === "auth";

      const hasTenantId =
        isAuthDestructuring &&
        declarator.id.properties.some((property) => property.key.name === "organizationId");

      if (!hasTenantId) {
        context.report({
          node: firstStatement ?? node,
          messageId: "noAuthDestructuring",
        });
      }
    },
  }),
});
Enter fullscreen mode Exit fullscreen mode

The rule doesn't prove that organizationId reaches your where clause. It proves the developer had to type the words. That's worth a lot, because the real failure mode isn't "I filtered by the wrong tenant", it's "I never thought about the tenant at all". Put the variable on line 1 and it stops being invisible. Unused variables are banned in the project, so the developer then has to use it somewhere, usually in a horizontal check.

export const GET = permissionMiddleware({
  permission: "project:read",
}).handler(async ({ request, ctx: { auth } }) => {
  const { organizationId } = auth;
  const projectId = request.nextUrl.searchParams.get("projectId");

  const project = await getProject({ id: projectId, organizationId });

  return NextResponse.json(project);
});
Enter fullscreen mode Exit fullscreen mode

Third rule: flag authorization functions and enforce their use

This second rule was a first step to make developers aware of tenant horizontal permission checks, but in most cases not enough. Some routes take an employeeId from the client, and "the caller belongs to org X" says nothing about whether employee Y sits inside the caller's perimeter. That check is a real function with real business logic. So how does a linter know it was called?

Make it nominally typed, then look for the name. Our third rule relies on branded types:

type Branded<T, BRAND extends string> = T & { readonly __brand: BRAND };

type OrganizationalPerimeterChecker = Branded<
  (params: { employeeId: string; organizationId: string }) => Promise<boolean>,
  "organizationalPerimeterChecker"
>;

export const canAccessEmployee: OrganizationalPerimeterChecker = async ({
  employeeId,
  organizationId,
}) => {
  // the well-tested business logic
};
Enter fullscreen mode Exit fullscreen mode

The brand is a machine-readable claim that a function is a security boundary, and once that claim lives in the type system, a linter can audit the whole codebase for it in seconds.

The ESLint rule walks the handler's call graph (every callee, then the callees of local functions) and asks the TypeScript type checker whether any of them carries it:

const services = ESLintUtils.getParserServices(context);
const checker = services.program.getTypeChecker();

const isPerimeterChecker = (identifier) => {
  const type = checker.getTypeAtLocation(services.esTreeNodeToTSNodeMap.get(identifier));

  return checker.typeToString(type).includes("organizationalPerimeterChecker");
};
Enter fullscreen mode Exit fullscreen mode

Teach the machine that writes the code

A lint rule fires only after the code is written. A human loses two minutes and an AI agent may need to rewrite the feature.

So we write it down twice, once for ESLint and once for the agent. The repo carries rule files that mirror the lint rules in prose, scoped by glob (the rule is simplified here):

---
paths:
  - "**/server/functions/*.ts" # Next.js server actions
  - "**/app/api/**/route.ts" # API routes
---

Every handler must be wrapped in `permissionMiddleware` with an explicit
permission, and its first statement must be
`const { organizationId } = auth;`.

If the handler receives an `employeeId` from the client, it must call an
`OrganizationalPerimeterChecker` before touching the row.
Enter fullscreen mode Exit fullscreen mode

The agent reads the convention before writing, instead of discovering it from a CI failure.

What this doesn't buy you

These rules aren't magic. Security is still owned by developers.

  • They prove a check exists, not that it's correct. Whether it's the right check, and whether its result is honoured, still belongs to unit tests.
  • Type-aware rules are slow. Anything touching the TypeScript program is orders of magnitude slower than a syntax-only rule, so scope those globs to server files and cache where you can.
  • Every new rule needs a migration path. Trade-offs are often made to allow temporary exceptions while the existing code catches up.

Conclusion

Three rules, and none of them is clever. One forces every entry point through a door that carries the permission check. One forces the tenant id onto the first line of every handler. One walks the call graph looking for a branded function proving a perimeter check happened.

A convention a human is asked to remember is a wish. The same convention expressed as an AST rule is a guarantee: it applies to the code you didn't write yourself, and it survives the person who introduced it leaving the team. On a codebase where a forgotten where clause means one customer reading another customer's payroll data, that difference is the entire point.

If you have thoughts about how to handle business security rules, feel free to comment!

Top comments (0)