Docs · Core Concepts

Authorization

Entitlement gating versus record-level Pundit policies, and how they compose.

One Shot answers two different authorization questions, and it answers them with two different mechanisms on purpose: whether a workspace is allowed to use a paid feature at all, and whether this specific user can act on this specific record. Conflating the two either lets a free account touch paid functionality, or makes every feature-gate check reimplement record-level scoping. Keep them separate.

Entitlement: the coarse paywall

RequireEntitlement answers "may this workspace use the paid surface at all?" Include it in any controller whose feature is paid:

class ProjectsController < ApplicationController
  include RequireEntitlement
  # ...
end

It checks Billing.for(Current.account).entitled? on every action, and redirects to the pricing page when the account isn't entitled. Admins are exempt, so an admin account can always reach a paid controller regardless of subscription status. Drop the include entirely for a feature that should be free.

See Gating a Feature for the full walkthrough of adding this to a new resource.

Record-level authorization: Pundit

Entitlement answers "is this workspace allowed in here," not "which rows can it see." That's Pundit's job. Every resource gets a policy in app/policies/, subclassing ApplicationPolicy, and two safety defaults apply everywhere:

  • Account-scoped by default. ApplicationPolicy::Scope filters any relation carrying an account_id column down to the acting user's account. Call policy_scope(SomeModel) instead of SomeModel.all, and a request for another tenant's record can't leak through, even if a developer forgets to scope a specific query by hand.
  • Deny by default. The base policy returns false for every action. A resource's own policy has to opt in per action; nothing is implicitly allowed just because a policy class exists.

Controllers call authorize @record before acting on it, and policy_scope(Relation) when listing records. pundit_user resolves to Current.user (guests get nil, so a policy can distinguish signed-in from anonymous without a separate check). ProjectPolicy is the reference implementation, and spec/policies/project_policy_spec.rb is the reference spec, including the tenant-isolation test: requesting a record that belongs to a different account by id should behave like the record doesn't exist, not like it's forbidden. A 404, not a 403, is what stops a URL from confirming that another tenant's data exists at all.

Roles

User#role is either member or admin. There's no admin UI in v1: an admin manages data through bin/rails console, and the admin? check exists mainly to exempt admin accounts from entitlement checks. You can use the same flag to gate an admin area you build yourself later.

Next

See how billing and entitlement fit together: How Billing Works.