In the first part of this series (link here), we introduced Rhino’s Model-Driven Development (MDD) approach, how this eases the vibecoding process, and explored how its dynamic routing system generates API endpoints directly from your models. This architecture allows for rapid development, but it requires an equally robust system to secure those endpoints. This post will dissect that next layer, breaking down how Rhino handles authentication (verifying a user's identity) and authorization (determining what that user can do).At the core of Rhino's security model are established and trusted Ruby gems: devise_token_auth
for token-based authentication and pundit
for fine-grained authorization. When the optional rhino_organizations
module is installed for multi-tenancy, the rolify
gem is introduced to enable sophisticated role-based access control (RBAC). We will explore how these components are seamlessly integrated within Rhino's default CrudController
and CrudPolicy
to provide a secure-by-default foundation for all your API resources, allowing you to build with confidence from the start.
The Foundation: Authentication with Devise
Before an application can determine what a user is allowed to do, it must first know who the user is. Rhino entrusts this critical function to devise_token_auth
, a gem designed for handling token-based authentication in Rails APIs. It manages the entire authentication lifecycle, including user registration, secure sign-in, and session management using authentication tokens that are exchanged with the client on each request.From the perspective of the authorization system, Devise's most critical function is to provide a verified current_user
object within the context of the controller for every authenticated API request. This object, representing the logged-in user, is the cornerstone upon which all subsequent permission checks and data scoping decisions are built. The Rhino::Authenticated
concern, included in the CrudController
, ensures that an unauthenticated request is rejected before any action is executed.
The Gatekeeper: Authorization with Pundit and Rolify
Once a user has been authenticated, Rhino must determine their permissions. This is the responsibility of the authorization layer, which is powered by the Pundit gem. Pundit provides a clean, object-oriented approach to managing permissions through dedicated "policy" classes.In a simple, single-user application, Pundit's authorization rules can be based directly on a user's ownership of a resource. However, for most real-world applications, a multi-tenant structure is required, where data is partitioned between different groups of users. This is where the rhino_organizations
module becomes essential. When installed, it fundamentally shifts the ownership model:* Resources are owned by an Organization
, not directly by an individual user. This aligns the data structure with a typical B2B SaaS model.
- The
rolify
gem is utilized to manage a user's permissions within each organization. AUser
is connected to anOrganization
through a specificRole
(e.g., admin, editor, viewer). Users can have different roles in different organizations.This structure provides a powerful and flexible foundation for Role-Based Access Control (RBAC), allowing permissions to be managed at the organization level rather than on a per-user basis, which is crucial for scalability and maintainability.
Secure by Default: The Principle of Least Privilege
Rhino's entire authorization model is built on a "deny by default" philosophy, a security best practice known as the Principle of Least Privilege (PoLP). This principle is first established in Rhino::BasePolicy
, the abstract class from which all other policies in the framework inherit.By default, BasePolicy
does two critical things:1) It defines all standard Pundit action methods (index?
, show?
, create?
, update?
, destroy?
) and makes them return false
.
2) It defines a Scope
class with a resolve
method that returns scope.none
, an empty ActiveRecord relation.This means that out of the box, an unconfigured policy grants no permissions and allows a user to see no data. Permission must always be explicitly granted.This is where Rhino::CrudPolicy
, the default policy for all resources, comes into play. It inherits from BasePolicy
but overrides its restrictive defaults with an intelligent, role-based logic.* With the Organizations Module: The default behavior is collaborative. A User
who has been assigned an admin
role within an Organization
is granted full CRUD permissions on all resources owned by that organization. Furthermore, any user who is simply a member of that organization is granted viewer
permissions. This means if two users are in the same organization, they can both list and view the organization's blogs, regardless of who created them. This provides a sensible starting point for team-based applications.
-
Without the Organizations Module: In a simpler setup, the
User
who is designated as therhino_owner_base
of a resource hierarchy is granted full CRUD permissions.
Tying It All Together: The Rhino::CrudController
in Action
The Rhino::CrudController
is the workhorse that exposes the standard RESTful API endpoints (index
, show
, create
, update
, destroy
) for a given resource. It is designed to be lean and highly effective, seamlessly integrating Devise, Pundit, and Rolify to enforce the authorization rules defined in the policies. Every action within the controller is rigorously protected by Pundit. # rhino/crud_controller.rb
module Rhino
class CrudController < BaseController
include Rhino::Authenticated
include Rhino::Permit
# Confirm we are calling authorize and scope correctly
after_action :verify_authorized
after_action :verify_policy_scoped, only: %i[index show]
# ... controller actions ...
end
end
Before we even get to the actions, notice the Pundit safety nets:* after_action :verify_authorized
: This ensures that the authorize
method is called in every controller action. If you forget to authorize an action, Pundit will raise an error, preventing you from accidentally creating an insecure endpoint.
-
after_action :verify_policy_scoped
: This applies only to actions that return collections of data, likeindex
. It ensures that you have usedpolicy_scope
to filter the data, preventing accidental data leaks.Let's examine theupdate
action to see how these pieces come together: # rhino/crud_controller.rb
def update
@model = authorize find_resource
@model.update!(permit_and_transform)
permit_and_render
end
This compact code performs a sequence of critical security and data-handling steps:
1) find_resource
: This private helper method retrieves the specific record from the database based on the id
in the URL parameters.
2) authorize @model
: This is the core authorization check. Pundit is invoked here. It automatically infers that it should look for a policy corresponding to the @model
's class (e.g., BlogPolicy
). Since one likely doesn't exist, it falls back to the default, Rhino::CrudPolicy
. It then calls the update?
method on an instance of that policy, passing in the current_user
and the @model
record.
3) permit_and_transform
: If authorization succeeds, this method from the Rhino::Permit
concern is called. It consults the same policy to get a list of attributes that are permitted for the update
action and sanitizes the incoming parameters, ensuring that a user cannot update fields they are not supposed to.
4) @model.update!(...)
: The model is updated with the sanitized parameters.
5) permit_and_render
: Finally, the updated record is serialized back to JSON, again using the policy to determine which attributes are safe to show, and sent as the response.
The CrudPolicy
as a Dispatcher
The most elegant part of this design is that CrudPolicy
does not contain the final authorization logic itself. Instead, it acts as a dispatcher. Its primary job is to determine the user's role and then delegate the authorization check to a more specific role-based policy.Let's look at the check_action
helper method within CrudPolicy
, which is called by methods like update?
: # rhino/crud_policy.rb
def check_action(action)
# For each role the user has for this record...
Rhino.base_owner.roles_for_auth(auth_owner, record).each do |role, _base_owner_array|
# Find the policy for that role (e.g., AdminPolicy)
policy_class = Rhino::PolicyHelper.find_policy(role, record)
next unless policy_class
# If that role-policy allows the action, authorize immediately.
return true if policy_class.new(auth_owner, record).send(action)
end
# If no role granted permission, deny.
false
end
This method is a clear illustration of the dispatcher pattern:1) roles_for_auth
: It first asks the system, "What roles does this auth_owner
(the current_user
) have in the organization that owns this record
?" This might return { "admin" => [Organization(id:1)] }
.
2) find_policy
: For each role found (e.g., "admin"), it looks up the corresponding policy class (e.g., AdminPolicy
). Rhino has a convention for this: it will first look for a highly specific policy like AdminBlogPolicy
, and if not found, fall back to the general AdminPolicy
.
3) send(action)
: It then instantiates that specific role-policy and calls the original action method (e.g., update?
) on it.
4) First true
Wins: The moment any role-policy returns true
, the CrudPolicy
short-circuits and grants access. If it checks all roles and none grant access, it returns false
.This design is powerful because it decouples the generic controller from the specific business logic. The CrudController
doesn't need to know anything about admins, editors, or viewers. It only needs to know how to talk to the CrudPolicy
, which handles the routing of the authorization query.
Applying the Dispatcher Pattern to Data Scoping
The same dispatcher pattern is applied to data scoping, which is arguably even more critical for preventing data leaks in a multi-tenant system. This logic resides in the nested Scope
class inside CrudPolicy
.When the CrudController
's index
action calls policy_scope(klass)
, Pundit invokes the resolve
method on CrudPolicy::Scope
. # rhino/crud_policy.rb
class Scope < ::Rhino::BasePolicy::Scope
def resolve
role_scopes = []
# Get every role for the auth owner across all their orgs
Rhino.base_owner.roles_for_auth(auth_owner).each do |role, base_owner_array|
base_owner_array.each do |base_owner|
# Find the scope class for that role (e.g., AdminPolicy::Scope)
scope_class = Rhino::PolicyHelper.find_policy_scope(role, scope)
next unless scope_class
# Instantiate the role's scope and get its resolved relation
scope_instance = scope_class.new(auth_owner, scope)
resolved_scope = scope_instance.resolve
# Add the SQL for this specific role and organization to our list
role_scopes << resolved_scope.where(tnpk(Rhino.base_owner) => base_owner.id)
end
end
return scope.none unless role_scopes.present?
# Combine all the individual SQL queries into one using UNION
scope.where("#{tnpk(scope)} in (#{role_scopes.map(&:to_sql).join(' UNION ')})")
end
# ...
end
The logic is analogous to the action check but operates on collections of data:
1) It iterates through every role the user has across all their organizations.
2) For each role in each organization, it finds the corresponding policy scope (e.g., AdminPolicy::Scope
, ViewerPolicy::Scope
).
3) It calls resolve
on that specific scope. AdminPolicy::Scope
might return scope.all
(can see everything in the org), while a hypothetical AuthorPolicy::Scope
might return scope.where(user: auth_owner)
(can only see their own posts).
4) It collects the SQL query for each of these resolved scopes.
5) Finally, it combines all the individual SQL queries into a single, efficient database query using UNION
. This returns a single ActiveRecord::Relation
containing every record the user is permitted to see across all their roles and organizations, which is then passed back to the controller.
Conclusion
The Rhino framework provides a layered, secure, and highly scalable architecture for API authorization. By composing the strengths of Devise for authentication, Pundit for authorization policies, and Rolify for role management, it creates a robust system that is secure by default. The CrudController
and CrudPolicy
act as the central nervous system, applying these security principles consistently to every resource you define. This elegant abstraction means developers can build feature-rich, multi-tenant applications with minimal boilerplate, focusing on their unique business logic while trusting that the foundation is secure. You only need to create a custom policy when the default "admin," "editor," and "viewer" roles are not sufficient for your needs.In our next post, we will dive deeper into customizing Rhino's behavior by overriding the default policies and exploring the various configuration options available on the model objects themselves.
Top comments (0)