Rails authorization
Status: Implemented for the Policy Expression grammar and its Scaffold consumers. The Policies owner (repository-only) lists the leaves that remain gaps.
Each Policy lowers to an Action Policy record rule and, unless only current_account gates and create forms consume
it, a relation scope; the Policies owner (repository-only) owns both.
Public, item, and gate authorization remain distinct; protected member lookup starts from an authorized relation, and
generated controls repeat the request authorization decision. The Policies
owner (repository-only) also states which destroy? aliases a complete
exact-source operation census permits. Authentication supplies the principal through current_account; generated
Policies, controllers, and views consume it with Action Policy.
On this page
- Direction
- Policy record lowering
- Policy rules
- Scaffold consumption
- Authorization coverage
- Private Policy gate
Direction
Lower explicit Policy meaning with Action Policy and generated denial tests in the first profile. Generalized Web Scaffold output consumes exact public, item, gate, and profile decisions in requests and presentation, and uploaded files follow the decisions of the pages that show them. Other record sources and Expressions, native clients, and broader generated proof remain open. The Scaffold lowering includes the admitted indirect collection's destination Policy scope.
The first Rails profile selects Action Policy as the fixed lowering. The cumulative public candidate generates:
- a policy object and operation method from
allow_when; - a named authorized relation from that same Policy Expression, built from foreign-key subqueries;
- a nullable
userauthorization context backed by the generatedcurrent_accountseam; - exact output-name and pinned Policy-runtime collision checks;
- controller and Scaffold integration that guards protected content and authorizes mutations;
- a
show_files?rule for each Entity with uploads, which Active Storage's controllers ask before serving a file (see Rails Fields); - relation-scoped index and member lookup, record and environment gates, and conditional view controls;
- generated positive, denied, guest, absent-record, broken-link, rule-and-scope agreement, and Scaffold integration tests.
A complete application-wide authorization lowering still needs:
- the Expression leaves the Policies owner (repository-only) lists, which current output emits as stubs;
- native Policy output and non-Scaffold consumers;
- framework-wide authorization-verifier coverage beyond the generated Scaffold paths; and
- representative-user, hosted, deployed, and production enforcement proof.
Policy record lowering
Ordinary CompilationInput derives an immutable PolicyRecordLowering from the exact same-generation Domain,
relationship, Account, Policy-resolution, Policy-type, Predicate-type, and Scaffold-definition evidence. It generates
no Policy unless the exact Field-only public Web Account realization succeeds. PolicyConditionBuilder turns each
typed Expression into a PolicyCondition tree, and PolicyConditionSource renders that tree as the rule body and the
scope body. A leaf outside the lowered vocabulary, a read of a Counter whose counting is left
out and so reads 0, directly, through the signed-in Account, or through an Association's
Predicate, a delegation to an omitted Policy, or a negated delegation or exists that depends on a stub makes the
Policy an Unwritten condition instead, which PolicyRecordFiles renders as the stub that Policy
stubs describes. An operation whose plain rule method is in the pinned Policy-instance method census,
such as present?, or is show_files?, which uploads generate, gets a private rule name with a _rule? suffix, such
as present_rule?. The Compiler writes every caller, and the Analyzer warns with
foundation_plan.rails_target.policy.rule_renamed. Current output still omits a Policy, with a GapSet reason, when a
generated Policy name collides with a Project or pinned runtime claim, when its renamed rule belongs to another Policy
on the same Entity, or when it is part of a delegation cycle.
A relation scope is emitted for every Policy unless all of its consumers are current_account gates or create
forms, and for every Policy an emitted scope delegates to. Admitted Scaffold consumers that look records up through
the relation keep their exact source pointers as the scope's demand; an item consumer without a scope is omitted.
A Policy whose rule needs the Account wraps its scope in if user&.id.present? and returns relation.none for a
guest. A delegation calls authorized_scope on the delegated Policy and repeats its Account guard.
The complete exact-source operation census records every Entity even when it owns zero Policies and independently
checks every Policy UUID and pointer against the submitted document. It permits destroy? to alias to a selected
standard manage? rule only when no exact authored destroy exists. Bookmark and Rating use that alias. An
explicit authored destroy would be direct; Oscar's custom manage_movie_catalog? remains the direct selected
rule for Movie, Person, and Credit destroy instead of acquiring an alias. Missing, stale, forged, or incomplete
census evidence omits the consumer.
PolicyRecordFiles emits one ApplicationPolicy with default_rule nil, one class and RSpec file per admitted owner
Entity, required relation scopes, and complete source provenance. Examples exercise authored operations and relation
visibility with actual records. The Policy owner (repository-only)
distinguishes emitted application cases from upstream identity edge cases. Setup limitations retain guest and
missing-ID guards and disclose remaining coverage in the README. The application-wide dependency plan adds the
qualified Action Policy block and projects the complete pair; application spec discovery runs Policy examples after
schema loading.
Policy rules
Lower allow_when with Action Policy. A generated Policy gains a record rule and, unless only gates and create forms
use it, a relation scope, as Policy record lowering describes; the
Policies owner (repository-only) lists the leaves that make it a stub.
Every request and displayed Association selects explicit public access or one exact Policy record. Generated
method-name collisions and paths with no useful bootable projection omit their consumers and list the target gap.
The Compiler never keeps an unguarded public consumer in their place. Contradictory duplicate Entity operations
remain semantic errors, though analysis
reports them only as warnings until format 0.24 (repository-only), and each
omitted Policy's GapSet record names the other. Emit default_rule nil,
so unknown operations in the realized Policy layer raise ActionPolicy::UnknownRule.
Record rules and relation scopes use user&.id.present? as their identity boundary wherever they read the
Account. Ownership then compares record&.user_id == user.id; self rules compare record == user; collection
exists uses any? over the preloaded Association; a singular path chains safe navigation; and delegation calls
allowed_to? with the explicit dependency Policy class and nullable related record. A conjunction joins its
children with &&, and a disjunction joins them with ||, parenthesized only where precedence requires it. A
negated singular exists allows a missing related record and negates its whole condition, because ! binds more
tightly than ==: record.movie.nil? || !(record.movie.status == "won"). Safe navigation handles expected absence
without swallowing a wrong method interface; where nil would not already be false, as for != or an ordered
comparison, the rule checks presence first. Guests, ID-less Accounts, missing records, and missing delegated
relationships remain denied. The Action Policy 0.7.6 ownership
examples use ordinary identity comparisons, its nested authorization API
accepts a nil record with an explicit with: class, and its scoping guide shows
authorized_scope inside a relation_scope.
Policy stubs
Under the code-worth-keeping requirement, a Policy the Compiler does not write keeps its class and every consumer, as the stub reach decision (repository-only) records:
def read?
# club.read_open is not written yet.
# README.md describes it; spec/policies/club_policy_spec.rb covers it.
false
end
relation_scope(:read) do |relation|
raise NotImplementedError,
"club.read_open is not written yet. README.md describes it; spec/policies/club_policy_spec.rb covers it."
end
Action Policy 0.7.6 sends authorize! and allowed_to? through the same rule call, so the rule denies everywhere:
a page answers 403, or sends a guest to sign-in, and the navigation link, controls, inline forms, and record links
that ask the rule stay hidden until it is written. The scope keeps its relation parameter, because Action Policy
defines the block as a method that receives it. Every item-authorized index, show, edit, update, and destroy looks
its records up through the scope, so those pages raise before the rule is asked. So do a parent's collection and
its nested pages, Reference options and submitted-ID lookups on another Entity's form, and Home when Home shows the
stubbed index. A Policy that delegates to a stub calls the stub's rule and scope unchanged. A negated delegation,
or a negated exists over records that delegate to one, would allow what the written rule may deny, so that Policy
becomes a stub too. PolicyStubs names the pages
that deny and raise for the README and for qualification.
Authentication supplies the principal through current_account; Policies do not repeat that responsibility.
The generated seam calls Rodauth's rails_account only for a logged-in session, and
rodauth-rails 2.2.0 builds the model from the resolved authentication account.
Dropping persisted? intentionally permits explicitly supplied unsaved or destroyed Accounts whose IDs match.
Rails 8.1.3.1 defines it as object lifecycle flags, not authentication or current row existence.
This replaces the redundant guards introduced in the initial public Policy renderer without changing admission,
the reviewed GapSet, preloads, Action Policy verification, or the authorization consumers.
Exact authored destroy is direct. A selected standard manage may supply conventional destroy? only when
the complete exact-source operation census proves no authored destroy; generated code aliases
destroy? to manage?. Bookmark and Rating meet that condition. An explicitly selected custom operation,
including Oscar's manage_movie_catalog, remains the direct rule and creates no alias. The Scaffold still owns
route selection and return behavior. Missing census evidence omits the alias and every dependent consumer.
The target-unlowered message.notifications relationship omits only that child. The admitted Message implicit
Ordering emits independently. Neither suppresses the six admitted Case participant Policy roots
or their other consumers.
Scaffold consumption
The Scaffold renderer consumes those decisions directly. Member lookup starts from the authorized relation when
required, and views repeat allowed_to? for protected content and controls. Member actions that share one lookup
load the record in a set_<record> callback, while each action keeps its own authorize!. A current_account gate
becomes an earlier callback, so it still authorizes before that lookup; the
Rails profile shows the shape. Relations preload the nested Policy,
control, and presentation graph under strict_loading; associated records may enter server memory even when their
display is denied. Action and collection-wide gates authorize before presentation queries. Public indexes, item
authorizations, environment gates, projection gates, and current-account profile authorization keep their distinct
records and preload ownership. The Rails profile owns the preload
lowering. Account is one authenticated /account destination. Its custom profile details and queries retain their
read Policy; editing retains the update Policy. Ordinary native clients admit neither Account nor protected Scaffold
navigation.
The cumulative Scaffold semantics emit Policy record projections plus their relation scopes. Oscar Party emits
User, Bookmark, and Rating rules; Bookmark and Rating each gain a manage scope. Case Chat emits User, Case,
Conversation, Participant, ConversationThread, Message, and Notification rules. Its participant-read scopes select
through Participant.active subqueries, and its delegating scopes call the delegated Policy's scope.
Generated Web controllers, relations, projections, controls, mutations, and custom Account details consume those exact
decisions. Photogram remains the neutral control and emits no Policy or Scaffold files.
The target-unlowered message.notifications relationship still omits that nested child. The delivery import skip
remains a separate disclosure; Message's exact sent_at, id implicit Ordering now emits. None withholds the
six Case participant Policy roots or their other admitted consumers.
Friendly HTML and Turbo Policy denial responses remain in the generated application for signed-in accounts. A guest is sent to sign in through Rodauth and returns afterwards, as the Policies owner (repository-only) describes.
Authorization coverage
For requests claimed as authorized, configure Action Policy verification from authored semantics rather than
controller-action names alone. An item-authorized collection uses verify_authorized_scoped; a detail, create,
update, gate-authorized collection, or destroy request uses verify_authorized. An explicitly public request,
including a public index, joins the controller's skip_verify_authorized only: list. This keeps realized public
access intentional without weakening coverage for neighboring realized actions. Generated controllers add no
unrequested action. An action whose Policy the Compiler cannot lower stays behind a stubbed Policy
that admits nobody.
Private Policy gate
PolicyGateQualificationInput is a second internal type. It reconstructs that same Account and registration
prerequisite, requires web-only output, and admits only the Movie index targeted by the Account's admitted
favorite_movie Reference, linked to one Account-owned
manage_movie_catalog Policy. Pure same-generation Policy resolution and typing must prove the exact comparison
current_record == current_account; the authorization row must independently select
environment/current_account as its gate record. Target and Project constant collisions for Action Policy,
ruby-next-core, ApplicationPolicy, and the Entity Policy fail before rendering. RSpec examples add no named test
class claim. This path
also qualifies the same fixed Account self surface against the Policy entry's generated claims. Its shared
ApplicationNavigation contains Account self, while Web separately composes the authorized Movie entry. iOS is
declined because this qualifier is Web-only. Public CompilationInput for this exact Association-registration Plan
emits no Policy record and retains its Policy omission in the reviewed GapSet. Only the private qualifier emits this
controller, view, navigation, and request gate; it does not enter the durable Compilation lifecycle.
The Policy path contributes Action Policy ~> 0.7.6 to the application-wide dependency plan. Projection from the
maximal universe pins Action Policy 0.7.6 and ruby-next-core 1.2.1. Five Policy-specific renderers emit
ApplicationPolicy, the same-Account UserPolicy, its generated RSpec example, the protected index controller, and
the read-only index view. Shared web tasks add the route, locale, pagination, and conditional navigation. The
application controller authorizes through current_account, rescues only ActionPolicy::Unauthorized through the
shared Policy denial response (repository-only), and leaves all other
exceptions visible. The index controller verifies authorization, authorizes current_account before it constructs the
Movie relation, and then paginates. No relation scope, member lookup, mutation, Association authorization, native
output, or broader Policy Expression is admitted by that private gate.
The Policy renderers likewise select their five paths only for the exact private gate.
A distinct private Policy-gate qualifier accepts optional private Association registration only when the gated
Movie has a direct required short_text Primary Descriptor; its one-hop descriptor shape omits that input. It
requires the Movie index targeted by favorite_movie and one Account-owned manage_movie_catalog Policy typed as
current_record == current_account and explicit environment/current_account gate-record evidence. It pins
Action Policy 0.7.6 and ruby-next-core 1.2.1, emits ApplicationPolicy, a same-Account UserPolicy, its generated
same-account RSpec example, and controller authorization before the Movie relation. Denial rescues only
ActionPolicy::Unauthorized through the shared handler: sign-in for a guest, the HTML 403 page for a signed-in
account, and empty responses for other formats;
navigation uses the same decision for visibility. This private
record-only slice has no relation scope, member lookup, mutation, Association authorization, native client, or
broader Expression support. Public record projection is separate and does not reuse this controller/view gate.