Writing
CanCan authorizes STI base classes before Rails reclassifies them
September 16, 2026 · Amit Solanki
A read-only “viewer” admin role, logging into a real store, saw a genuine bug: visiting Orders or Products rendered the correct page every time, and flashed a red “Authorization Failure” banner every time too, on every single load.
The fix was written by Claude, and it was wrong in a way that took a second pass to see. Not wrong about the bug - the flash went away, the pages loaded clean. Wrong about what it handed out along the way: a role with no read access to gift cards anywhere in the admin could now generate and download a full gift card export.
Claude caught that too, in the same review session that found the Doorkeeper defaults. I want to be exact about the sequence, because the interesting part isn’t that a review caught something. It’s that what it caught was its own work, and the reason the gap got in is legible in the code it wrote.
The trigger, briefly: the shared export-modal partial both pages render embeds a Turbo Frame that fetches eagerly the moment it’s inserted into the DOM, whether or not the dialog is ever opened. Without export permission, that background request 403s, and the redirect lands the viewer right back where they started with the flash attached. Granting export permission fixes the symptom - the question is what, exactly, to grant.
The first surprise: authorize_admin checks two things, not one
The obvious first attempt - grant :new and :create on the export model - still 403’d. Spree
admin’s authorize_admin checks :admin on the record and the actual action, separately.
Missing either one fails the whole check. Not the interesting part of this story, but the kind of
thing that costs you fifteen minutes if you don’t already know it, so: know it.
The real one: authorization runs before Rails reclassifies the object
Exports in this admin are Single Table Inheritance - Spree::Export is the base class,
Spree::Exports::Orders, Spree::Exports::Products, Spree::Exports::GiftCards, and several
others are STI subclasses sharing one table. The natural instinct, wanting to scope this tightly,
is to grant the permission on just the three subclasses this role should actually touch -
Orders, Products, Customers - and leave the rest ungranted.
That doesn’t work, and the reason why is a genuine controller-lifecycle detail worth knowing
outside this one bug. Spree::Admin::ResourceController#new reclassifies the object to its real
STI type via becomes! - but it does that from inside its own method body, as part of
invoke_callbacks(:new_action, :before). Rails’ own before_action chain - which is where
authorize_admin runs - has already completed by the time that method body executes. So the
authorization check for :new is always evaluated against the plain, unconverted
Spree::Export base class. Always. It doesn’t matter what type parameter was submitted in the
request; the object CanCan is checking hasn’t been turned into that type yet, and won’t be until
after the check already ran.
# Grant is on the base class - the only class ever actually authorized
# against for :new, no matter what `type` param was submitted.
can %i[admin new], Spree::Export
A subclass-only grant can never satisfy this. The class-level check genuinely cannot distinguish which export type is being requested before authorization happens, because the reclassification that would let it distinguish hasn’t happened yet.
The gap that got through the first fix
The first fix, needing :create to match the same base-class pattern :new required, granted it
the same way:
# The first attempt - matches the pattern above, and is wrong.
can %i[admin new create], Spree::Export
This satisfied the actual bug - the flash was gone, the pages loaded clean. It also meant a
viewer-role account, with zero read access to gift cards, coupon codes, newsletter subscribers,
or product translations anywhere else in the admin, could generate and download a real export of
any of them. Not through a UI path - there isn’t one, the Export button only ever appears on
pages this role can see - but through the same Spree::Admin::ExportsController#create endpoint,
with nothing stopping it but the request params.
Claude’s second pass over the ability file caught it, the same review session that turned up the Doorkeeper defaults, and it caught it by asking something the first pass never asked. Not “does this grant work?” but “what else does this grant?”
ability.can?(:create, Spree::Exports::GiftCards.new)
# => true
That’s the whole bug, in one line. :create was granted unconditionally on the base class, and
CanCan’s base-class grant covers every subclass by default.
The fix: condition on the type, but only where it’s real
:new checks against the bare model class, with no instance and no attributes to test a
condition against at all - a hash-conditioned rule checked at the class level can’t discriminate
by definition, which is why it has to stay unconditional there. :create checks against a real
instance, and a CanCan hash condition matches on the record’s actual attribute values - not on
whether the object has been Ruby-reclassified into its STI subclass via becomes!. Confirmed
directly: Spree::Export.new(type: "Spree::Exports::GiftCards").type is the string
"Spree::Exports::GiftCards" whether or not the object’s own Ruby class has been switched, and
that’s the value a type: condition actually reads. So the same condition that’s a no-op against
the class-level :new check works exactly as intended against :create, once the submitted
type param has been assigned onto the object:
ALLOWED_TYPES = %w[Spree::Exports::Orders Spree::Exports::Products Spree::Exports::Customers].freeze
def activate!
# :admin and :new stay unconditional on the base class - they're
# checked before the object has real attributes to condition on.
can %i[admin new], Spree::Export
# :create is conditioned on the real STI type - this is the check
# that actually matters, since it's what generates and persists
# the export.
can :create, Spree::Export, type: ALLOWED_TYPES
end
Same check, same object, after the fix:
ability.can?(:create, Spree::Exports::GiftCards.new)
# => false
ability.can?(:create, Spree::Exports::Orders.new)
# => true
:new stays broad - opening the export dialog is unavoidably possible for any export type, a
real, accepted, documented consequence rather than a silently narrowed claim. :create - the
action that actually persists a record and generates a downloadable file - is genuinely
restricted. The asymmetry isn’t a compromise; it’s the accurate reflection of what each check
actually has to work with: one against a bare class with no attributes at all, the other against
a real instance whose type attribute already holds whatever the request actually submitted.
The lesson
Two lessons, actually, stacked:
becomes! runs after authorize_admin, not before it. Any authorization check against an
STI base class, positioned before whatever callback does the reclassification, will always see
the base class - granting the subclass instead doesn’t help, and granting the base class
unconditionally is the only thing that satisfies it. Know which side of that reclassification
your check runs on before deciding how to scope a grant.
A permission that has to be broad for a structural reason isn’t an excuse to leave the next
one broad too. :new being unavoidably class-level didn’t mean :create had to be. The two
checks run at different points in the object’s lifecycle against different amounts of real
information, and treating them identically - matching the pattern instead of re-deriving what
each one actually needs - is exactly how the second, more severe gap got through the first fix.
The fix for that wasn’t more caution in general; it was one direct ability.can? check against
the specific object that mattered, run deliberately before shipping rather than assumed.
Worth naming the mechanism precisely, because it is the one to expect. Matching a nearby pattern is what a language model does well - it is why the first fix arrived in seconds and why it read as correct. It is also why it was wrong. The same property produced both, and general caution does not address it. What addresses it is asking, about each grant, the question a pattern cannot answer: not does this work, but what else does this allow.
The checklist
- Before granting a permission on an STI base class, find out whether the object is reclassified before or after that specific authorization check runs. If before, a subclass-only grant will silently never match.
- A grant that has to be broad at one action doesn’t mean the next action needs the same breadth
- check each action against what the object actually looks like at that point in its lifecycle.
- When a permission grant is scoped with a hash condition, test it with
ability.can?against the specific object that would prove the restriction real - not just the object that proves the intended case works. authorize_admin-style helpers that check more than one thing (here::adminand the action, separately) will fail on a partial grant in a way that looks identical to a config that’s simply wrong. Know what your authorization helper actually checks before debugging why it’s failing.
The platform behind this post is written up in the case study - Square POS as the source of truth, two courier networks quoting head-to-head, five published gems.
Working through something similar?