Database-backed permissions for Ruby on Rails.
Resource CRUD and custom actions, field-level read/edit access with masking, and row-level query rules, edited at runtime and cached per user.
Overview
PermissionForge implements a three-tier permission model:
| Tier | Scope | Controls |
|---|---|---|
| Resource | Model | Can this user read/create/edit/delete Customers? Run the export action? |
| Field | Column | Can this user see email? Masked or in full? Edit name? |
| Rule | Row | Only records where owner_id = current_user.id
|
Permissions live in your database as Permission Sets (collections of resources, fields, actions and rules) that are assigned to users, optionally grouped, and edited in the built-in admin while the app runs. Each user's merged permissions are cached as one hash.
Features
-
Resource-level CRUD and custom actions per model (
can_read?,can?(:export, :Customer)), and an optional controller guard that checks them before an action runs (Controller Actions) -
Field-level read/edit access, enforced on the model's attribute readers and serialization (
MaskedAttributes), on incoming params (PermitsAttributes) and on response hashes (FiltersResponseAttributes) -
Field masking with four built-in strategies (
full,email,last4,first1_last4) and your own -
Row-level query rules: 15 operators, 3 join types, values compared in the column's type on PostgreSQL, MySQL and
SQLite; applied by
Model.find_by/find_by!andModel.permission_scoped -
Optional query scoping: a
default_scopefor every query, detection of queries that skipped the rules, and PostgreSQL Row-Level Security policies (Query Scoping) - Muting Permission Sets that subtract access, time-bound assignments, and activation conditions (session flag, IP allowlist, login hours), evaluated for each request
- Audit log of every permission change made through the models, written in the change's transaction
- Admin UI with no host asset dependencies, and an opt-in JSON admin API
- Personal-data helpers: masks suggested for personal-data columns when fields are generated, a field auditor, and log parameter filtering for those columns (on by default)
-
Any Rails cache store (
Rails.cacheover Redis, Solid Cache, Memcached, file; or in-memory for tests) -
Multi-tenancy through
acts_as_tenant, and three locales (English, Albanian, Macedonian) - No hard dependencies on Devise, Pundit, Rolify or any auth framework: every integration point is a lambda
How it differs from Pundit, CanCanCan and Action Policy
Those are policy libraries: you write rules in Ruby and call them where you decide. PermissionForge is a permissions
platform: rules are data that admins edit at runtime, and the gem hooks into your app to apply them. It overrides
find_by on models you protect, wraps attribute readers and serialization, can add a default_scope, runs checks at
boot, and mounts an admin. That reaches further than a policy library and couples more tightly to your models; it can
sit next to one (for example Pundit for controller decisions, PermissionForge for fields and rows).
Installation
Add to your Gemfile:
gem 'permission_forge'Then run:
bundle install
bin/rails generate permission_forge:install
bin/rails db:migrateThis creates config/initializers/permission_forge.rb and a migration with 9 tables: the 8 permission tables and the
audit log, prefixed with permission_forge_ by default.
Then mount the admin UI and give it credentials. It is behind HTTP Basic auth and answers 401 until both are set:
# config/routes.rb
mount PermissionForge::Engine, at: "/permissions"# config/initializers/permission_forge.rb: uncomment Option A ("Admin screens")
config.http_basic_auth_user = ENV['PF_ADMIN_USER']
config.http_basic_auth_password = ENV['PF_ADMIN_PASSWORD']Set PF_ADMIN_USER and PF_ADMIN_PASSWORD in the environment and open /permissions. To sign admins in with your
own authentication instead, see Admin UI
(base_controller_class). bin/rails permission_forge:audit lists what is still open before production.
Generator options:
-
--skip-migration: no migration (for apps with existing tables) -
--table-prefix=PREFIX: table name prefix (defaultpermission_forge_;''for none) -
--user-id-type=TYPE: type of the user id columns,bigint,integer,uuidorstring(default: detected from your user model's primary key, so run the generator after your users table exists) -
--tenant=MODEL: add a tenant column for multi-tenancy (--tenant-id-type=TYPEto override its detected type)
Requirements: Ruby 3.2+, Rails 7.1 to 8.1, PostgreSQL, MySQL or SQLite.
Upgrading? Read the CHANGELOG and the migration guide.
Quick Start
1. Tell it who is signed in
The generated initializer reads Current.user when your app has a Current class that answers user (as Rails 8's
authentication generator creates). Otherwise point it at your own lookup:
# config/initializers/permission_forge.rb
PermissionForge.configure do |config|
config.user_class = 'User'
# As generated; or however your app knows the user
config.current_user_resolver = -> { ::Current.user if defined?(::Current) && ::Current.respond_to?(:user) }
config.cache_backend = :rails_cache
endUntil it returns a user, nobody is signed in and nil_user_strategy decides (see the checklist below).
Configure PermissionForge in config/initializers only. The gem's models (PermissionForge::PermissionSet, ...) are
autoloaded by your application right after the initializers and read user_class, audit_user_class and
tenant_model as they load; a setting changed later (in after_initialize, a console) does not reach a loaded class
(PermissionForge logs a warning). Reference a model from a method (your models, controllers, jobs and rake tasks), not
from the top level of an initializer, an engine or railtie initializer, or a file required from
config/application.rb: the boot then fails with a NameError that says so. To change one of its classes (include a
module, add a callback), use Rails.application.config.to_prepare or
Rails.autoloaders.main.on_load('PermissionForge::PermissionSet'), which run again after every code reload;
config.after_initialize runs once, so use it only to read.
2. Include the concern in your User model
class User < ApplicationRecord
include PermissionForge::Concerns::PermissionedUser
endEvery private method PermissionForge adds to your User class starts with pf_, so a method of your own never
replaces one of the gem's. The public API is the documented one (permissions_hash, regenerate_permissions,
invalidate_permissions, permission_cache_key, permission_invalidation_stamp, can?, can_*?,
permissions_for, permissions_hash_for, masked_field?, mask_strategy_for, field_permissions); override
those, not the private helpers.
3. Protect models with row-level rules (optional)
class Customer < ApplicationRecord
include PermissionForge::Concerns::PermissionScopedFindable
endThis applies the user's rules to the class-level Customer.find_by / find_by! and adds a chainable
Customer.permission_scoped relation. Nothing else is scoped by default: find, where, scope.find_by and
association traversal return every row. Start from permission_scoped, or turn on one of the
query-scoping levels. A user with no
rules for a model gets every row.
4. Create a Permission Set and assign it
pset = PermissionForge::PermissionSet.create!(name: 'Sales Team', is_active: true)
resource = PermissionForge::Resource.create!(
permission_set: pset,
name: 'Customer',
read_access: true, create_access: true,
edit_access: true, delete_access: false
)
PermissionForge::ResourceField.create!(
resource: resource, name: 'name', label: 'Name',
read_access: true, edit_access: true
)
user = User.first # the person who gets the set
PermissionForge::PermissionSetUser.create!(permission_set: pset, user: user)Each change invalidates the cached permissions of the users it affects; the next read rebuilds them. The admin UI
(mounted at /permissions in Installation) does the same through forms.
5. Check permissions at runtime
user.can_read?('Customer') # => true
user.can_delete?('Customer') # => false
user.permissions_for(:Customer)
# => { read_access: true, create_access: true, edit_access: true, delete_access: false,
# view_all: false, modify_all: false }Resource CRUD flags are answers to these calls. They do not by themselves stop a getter or find_by: enforce them
where you call can_*?, or with ControllerActionGuard. Field access is decided by the field rows.
Production hardening checklist
Several defaults fail open so that installing the gem changes nothing until you configure it. Before production, go
through this list. bin/rails permission_forge:audit reports most of these settings.
config.preset = :strict closes every row below that a setting can close, except masking with nobody signed in (an
explicit choice: see its row); a setting you make yourself always wins over the preset. New installs get the safe
part (action_guard_nil_user_strategy = :deny, conditional_sets_strict_mode = true, cache_backend = :rails_cache)
and the preset as a commented line with what it costs. The rows that need your code (base_controller_class,
admin_assignment_authorization, audit_log_authorization, user_active_check) are not in a preset.
bin/rails permission_forge:audit prints the preset and, under :strict, every option set looser. See
Presets.
| Default | What it means | To close it |
|---|---|---|
nil_user_strategy = :bypass |
With no current user (jobs, console, a resolver that returns nil), find_by, permission_scoped, auto-scoping and the RLS policy apply no rules; so does the controller guard unless action_guard_nil_user_strategy is set |
:deny (not found) or :raise. Do not include PermissionScopedFindable in the user model: under :deny sign-in lookups return nil (permission_forge:audit reports user_model_scoped) |
masked_attributes_nil_user_strategy = :bypass |
Attribute readers, attributes and as_json of a masked model return the stored values when nobody is signed in, whatever nil_user_strategy says |
:hide (every guarded attribute reads as hidden with no user; the primary key and the raw paths are untouched), and set the current user in the jobs and mailers that read masked models. Not part of config.preset = :strict. The user model (user_class and its subclasses) stays readable for sign-in; validations read stored values |
response_filter_nil_user_strategy = :bypass |
FiltersResponseAttributes returns every attribute when there is no user |
:block |
auto_query_scoping = :off |
Only class-level find_by / find_by! and permission_scoped apply row rules |
:filter_only, and use permission_scoped for join rules; consider RLS on PostgreSQL |
unconfigured_action_strategy = :allow |
A controller action no Permission Set manages is allowed; unmapped actions are always allowed | :block |
action_guard_nil_user_strategy = nil |
The guard follows nil_user_strategy (:bypass) for signed-out requests to mapped actions |
:deny (new installs get it from the generator) |
conditional_sets_strict_mode = false |
With an empty request context (no resolver configured), every conditional Permission Set applies, MFA- or IP-gated grants included | A request_context_resolver that returns the request's IP and session flags, and true: without a request context, conditional granting sets are then dropped and conditional muting sets still apply |
mask_virtual_attributes = false |
Virtual readers (methods, store accessors, attribute-API attributes) are not masked on the model; only PermitsAttributes and FiltersResponseAttributes apply their field rows |
:auto (or a list of models), after checking that the models' own callbacks do not need the raw values |
extra_fields_default_access = :grant |
A declared virtual field no row configures gets the resource's read and edit access, unmasked | :unconfigured |
cache_backend = :memory |
Per process: invalidations and auto-applied masking do not reach other processes (boot warning in production) |
:rails_cache over a shared store (Redis, Solid Cache, Memcached) |
http_basic_auth_enabled = true |
Without credentials the admin answers 401 (closed). With credentials it is one shared login with full admin power. With Basic auth off and base_controller_class left as ActionController::Base, the admin is open to anyone |
Set base_controller_class to your authenticated admin controller, then turn Basic auth off |
admin_assignment_authorization = nil |
Every admin who reaches the admin can assign anyone to anything | A lambda that checks the acting admin |
audit_log_authorization = nil |
Every admin (and every API caller api_auth admits) can read and export the whole audit log |
A lambda that returns exactly true for those allowed |
base_controller_class / api_base_controller_class
|
With tenancy on, a request with no current tenant is refused (403) | Controllers that authenticate and set the tenant |
user_active_check |
Only reported by the security audit; an inactive user keeps every grant | Revoke the assignments of users you deactivate |
Upgrading from 0.13: once the last 0.13 process is gone, run bin/rails permission_forge:cache:clear (see the
CHANGELOG).
Already closed by default, keep them so: api_require_authorization = true (API changes need
api_action_authorization and api_assignment_authorization), admin_tenantless_access = :deny,
nested_attributes_parent_fallback = false, unconfigured_field_strategy = :block, and the API is off
(api_enabled = false). If you use PostgreSQL RLS, run bin/rails permission_forge:rls:sync after each upgrade.
See Configuration for every option.
What is not intercepted
Masking and row rules work at the model layer. These paths read the database or the raw attributes directly:
-
Values:
pluck,pick, calculations (minimum(:email),maximum,sum,group(:email).count),select("email AS contact")and other SQL aliases (also throughfind_by_sql),connection.select_*,*_before_type_cast/read_attribute_before_type_cast/attributes_before_type_cast,*_in_database/attribute_in_database,_read_attribute,attributes_for_database, the dirty-tracking helpers (*_was,changes,changes_to_save),Marshal.dumpandto_yaml/YAML.dump. UseFiltersResponseAttributeson hashes you build this way. -
Presence:
where(email: value).exists?tells whether a value is stored, andattribute_present?(:email)answers for the stored value even when the column is hidden (theemail?reader follows the guard). -
Rows: with
auto_query_scoping = :off, everything except class-levelfind_by/find_by!andpermission_scoped(for examplefind,where,scope.find_by,association.find_by). With auto-scoping on,unscoped,find_by_sqland raw SQL still skip it; PostgreSQL RLS covers them for connections that set the user's session variable (withnil_user_strategy = :bypass, a connection without it sees every row). -
Writes that skip callbacks (
update_all,delete_all, raw SQL) are not audited.
Details: Masked Attributes, Query Scoping.
Audit log
Every create, update and destroy, made through the models, of a Permission Set, Resource, field, action, rule, group,
assignment or membership is written to the audit_events table inside the change's own transaction. Under the default
audit_change_failure_strategy = :raise, a change whose event cannot be written rolls back. Each event stores the
actor, source, request id, IP, an optional reason, a correlation id, and the changes or a snapshot of the deleted row.
Behind your own admin controller (base_controller_class) the actor is the user current_user_resolver returns.
Under HTTP Basic auth your app's sign-in does not run in the admin, so admins share one login name unless
audit_actor_resolver names them:
PermissionForge.configure do |config|
# Rails 8's authentication generator (with Devise: controller.request.env['warden']&.user(:user))
config.audit_actor_resolver = ->(controller) { Session.find_by(id: controller.request.cookie_jar.signed[:session_id])&.user }
config.audit_retention_period = 2.years # purge: bin/rails permission_forge:audit_events:purge
endRead it at <mount path>/audit, export it as CSV or JSON, or call GET /api/audit_events. It supports evidence for
access-control and accountability requirements; it is not a certification. It does not record writes that skip
callbacks or, by default, who read data. The model refuses updates and deletes, but update_all, delete_all and
direct database access still reach the table, so protect it at database level, forward it, synchronise clocks and set
retention. See Audit Log.
Documentation
| Guide | Description |
|---|---|
| Getting Started | Install, first Permission Set, first checks |
| Configuration | Every option and its default |
| Architecture | Data model, merge rules, cache and invalidation |
| Usage | Checking access, custom actions, fields, masking, rules, filtering |
| Masked Attributes | Model-level field enforcement and what it does not cover |
| Field Filtering | Read-path and write-path filtering, nested attributes, virtual fields |
| Row-Level Rules | Operators, join types, association paths, STI |
| Query Scoping | Auto-scoping, unscoped-query detection, PostgreSQL RLS |
| Muting Permission Sets | Subtracting access |
| Activation Conditions | Session flags, IP allowlists, login hours |
| Assignment Expiration | Time-bound assignments and clean-up |
| Permission Set Groups | Grouping Permission Sets |
| Controller Actions | Permit or block controller actions per Permission Set |
| Admin UI | Mounting, authentication, screens |
| Admin JSON API | Authentication, CSRF, endpoints, pagination, serializers |
| Audit Log | What is recorded and what is not, exports, retention, protecting the table |
| Multi-Tenancy |
acts_as_tenant integration |
| Security Audit |
rake permission_forge:audit and its findings |
| GDPR Data Handling | What the gem evidences and what the host must add |
| HIPAA | Technical safeguards: what the gem evidences and what the host must add |
| Masking Disclaimer | Masking is not anonymization |
| Table Name Prefix | Custom prefixes, moving between prefixes |
| Internationalization | Locales and overrides |
| Performance | Overhead and cache costs |
| Testing | Testing permissions in your app |
| Migration Guide | Upgrading between versions |
| Tips & Helpers | Common patterns and pitfalls |
| AI assistant instructions | A section to paste into your app's CLAUDE.md / AGENTS.md
|
Versioning and stability
PermissionForge follows Semantic Versioning and is pre-1.0. Until 1.0, a minor version (0.x.0)
may change defaults and behaviour; every such change is listed under Changed in the
CHANGELOG with its opt-out and upgrade
steps. Patch versions (0.x.y) only fix bugs. The supported surface is the configuration options, the concerns your
models include, the services documented in the guides, the error classes and the permission hash format
(Architecture). Other classes are
internal and may change in any release: classes marked @api private in the source (and hidden in the YARD docs) are
internal. A public API is never renamed or removed in one step: the release that changes it keeps the old name
working for at least one minor version and warns through PermissionForge.deprecator (registered with
Rails.application.deprecators, so config.active_support.deprecation governs it), and the CHANGELOG lists it under
Deprecated with the version that removes it. Security fixes go into the latest release only.
Security
Please report vulnerabilities privately, as described in SECURITY.md.
Contributing
Bug reports and pull requests are welcome. See CONTRIBUTING.md for the setup, the test suites and the rules the code follows, and the Code of Conduct.
Acknowledgements
PermissionForge's permission model (resource-level CRUD, field-level visibility and row-level query rules) was inspired by Salesforce's Profile and Permission Set architecture, adapted for Ruby on Rails.
Built at Confidee, where it controls access across internal operations, customer portals and supplier portals.
License
The gem is available as open source under the terms of the MIT License.