Engineering

SaaS authorization cannot end in the controller

How to protect multi-tenant SaaS operations whether they are called by a controller, job, import or console.

An authorize! call in a controller is a good start. In a multi-tenant SaaS application, it is not the final security boundary.

The same operation may be invoked by an HTTP request, background job, import, administrative console or another business command. If the rule exists only in the controller, a direct service call can bypass it.

The complete access context

A critical command should receive an explicit execution context:

  • the actor,
  • the organization,
  • membership and organization role,
  • module-specific permission,
  • impersonation state,
  • invocation source.

This should not become an unstructured hash passed throughout the codebase. A small value object can describe verified access through a clear contract.

Controller / Job / Import
           ↓
Verified Access Context
           ↓
     Business Command
           ↓
      Domain Decision

Three layers, three responsibilities

The UI exposes only the actions available to the user. That reduces frustration and guides the workflow, but it is not a security control.

The policy protects the endpoint and query scope. It should reject an unauthorized request early and prevent a record from another organization from being loaded at all.

The business command verifies the conditions that matter to the operation again. It does not assume every caller already performed the right checks. The rule therefore remains valid outside HTTP.

This need not mean copying logic three times. A shared access object can answer membership and permission questions, while the policy and command use that same contract for different responsibilities.

Tenant scope before identifier

The organization should not be inferred from an arbitrary user-controlled parameter. First determine the organizations available to the actor. Only then locate the record from the URL inside that scope.

current_organization.records.find(params[:id])

The order matters. A valid identifier belonging to another tenant still results in a safe not found instead of disclosing that the record exists or relying on a later check.

Background jobs need the same care. Persisting only a record_id is not enough. A job should receive stable organization context and an actor, or explicitly run as the system. It must reload the data through the correct scope when it executes.

Impersonation has two identities

When an operator assists a customer through impersonation, an event has at least two identities: the business actor and the real operator. Authorization must apply the restrictions of an impersonated session, while audit evidence preserves both identities.

Not every action available to an administrator should be available while impersonating. Exports, security changes and cross-organization operations often require the operator’s direct session.

The final rule

Do not trust the place an invocation came from. Trust an explicitly verified operation context.

Controllers, policies and commands are not competing patterns. Together they create layered protection: the UX remains clear, queries remain tenant-scoped, and the domain cannot be bypassed through an alternative execution path.

This approach is part of Thinqcraft’s broader production technology standards. If you are restructuring authorization in an existing Rails system, start a conversation.

Related services

Services connected to this article.

ModernizationModernize Rails systems while production keeps running.

Rails upgrades, React/Vite migrations, PostgreSQL tuning, Linux production systems, NGINX, SSL, Puma, background workers, queues and deployment flows.