Authorization & Policies
A Pundit-style policy layer that answers one question: is the current user allowed to perform this action on this record?
Deny by default
Policies start out forbidding everything — you opt in to what you want to allow. A record class with no matching policy is denied, so a forgotten rule fails closed rather than open. Scaffold the whole thing, including a User model and login flow, with one command.
1 Generate it
soli generate auth scaffolds the full Devise-style auth suite — login/signup, password reset (hashed one-time tokens, 2h expiry), email confirmation with resend, remember-me (HttpOnly + Secure persistent cookie, revoked on password reset), per-IP throttling on all three credential endpoints, account lockout with auto-unlock — and the policy layer together. Thresholds and the confirmation gate are constants/toggles at the top of the generated app/models/user.sl; configure SMTP (SOLI_SMTP_*) so the emails go out, and set APP_BASE_URL to your https:// origin — unset in production, reset and confirmation emails carry http://localhost:5011 links.
Sign-in tells an attacker nothing
An unknown email and a wrong password return the same message and take the same time: the miss path spends the same Argon2id work instead of returning early, so response timing is not an enumeration oracle either. The specific “account locked” message appears only after the password has been verified — the owner learns what happened, someone guessing does not learn that the address exists. AUTH_MIN_PASSWORD_LENGTH (default 12) is enforced through User#password_error, which both sign-up and password reset call, so the reset flow cannot install a password registration would have rejected.
soli generate auth
soli db:migrate up # create the users collection
soli serve . --dev # then visit /signup, /login
Files in app/policies/ are auto-loaded into the global scope at boot, like models. Restart the server after adding a new policy.
2 Write a policy
A policy is a class named <Model>Policy with one predicate per action: index?, show?, create?, new?, update?, edit?, destroy?. Inside it, this.user is the current user and this.record is the record being checked.
class PostPolicy < ApplicationPolicy
def show?
true # anyone may read a post
end
def update?
return false unless this.signed_in?() # guard the nil user first
return this.user["_key"] == this.record["author_id"]
end
def destroy?
return this.update?() # same rule as update
end
end
ApplicationPolicy returns false for every predicate, so you only override what you allow. new? falls back to create? and edit? to update? unless overridden.
3 Authorize in a controller
Call authorize(record) at the top of an action. It builds the matching policy and calls the predicate for the current action — a falsey result raises 403 Forbidden; otherwise it returns the record.
def update
post = Post.find(params["id"])
authorize(post) # 403 unless PostPolicy#update? is true
post.update(this._permit_params(params))
return redirect(post_path(post))
end
# Pass an explicit action when it differs from the controller action:
authorize(post, "show")
Helpers
| Helper | Available in | Returns |
|---|---|---|
authorize(record, action?) | controllers / policies | the record, or raises 403 |
policy_for(record) | controllers / policies | the policy instance |
current_user() | controllers / policies / views | the signed-in User, or nil |
signed_in?() | controllers / policies / views | true when signed in |
current_action() | anywhere | the in-flight action name |
forbidden(message?) | anywhere | raises 403 immediately |
current_user is populated per request by the generated load_current_user middleware, which reads the user id from the session and loads the User. A denied request renders through the standard error pipeline, so a custom app/views/errors/403.html.slv is used automatically.