Authorization as data you edit in a UI, not rules you hardcode.
Permissions come from your routes. A role is a row of ticked cells. One ambient allowed_to? answers the same question in controllers, views, and components.
- Approval workflows where the author cannot sign their own work.
- Per-record grants for multi-tenant teams.
- Roles that change weekly, without a deploy.
Built for experimentation and report-mode pilots while we harden the remaining security items. The last gate to 1.0 is one real host on config.enforcement = :report, then :enforce (#116).
Edit role: Reviewer
One row per controller. Tick a cell. That is the policy, live on the next request.
| controller | read | create | update | destroy | approve |
|---|---|---|---|---|---|
| orders | |||||
| reports | |||||
| invoices | |||||
| projects | |||||
| expenses |
Stop scattering if user.admin? across the codebase.
Hardcoded checks drift, hide in views, and need a deploy to change. CurrentScope moves the policy into data your team edits in a UI, and reads it back the same way everywhere.
Rules compiled into the app
Every rule is code. Changing what “Reviewer” means is a pull request, a review, and a deploy. The view and the controller can silently disagree.
# scattered, and a deploy to change if user.admin? || user.role == "reviewer" approve!(report) end # …and did the view remember to hide the button?
Policy is data, read one way
Roles are editable rows in the mounted UI. Controller, view, and component ask the same resolver. One ambient context, no current_user threading.
# same answer in controller, view, component allowed_to?(:approve, report) # change "Reviewer" in the UI. No deploy.
Add the gem, run the generator, and it is wired.
Permissions from your routes
Every controller#action pair is a permission. Add an OrdersController and its actions appear in the grid. Zero wiring.
Roles as rows, not classes
A role is a named, editable bundle of ticked cells on a controller × action grid. Change what “Reviewer” means without a deploy.
Scoped roles
The same role on one specific record. “Editor of Project #7” grants nothing on Project #8, enforced by the same resolver.
Separation-of-duties veto
Opt in by listing actions. Once on, whoever initiated a record can never approve it. Not grantable, not clickable, overriding even full access. The full anti-fraud story →
Fail-closed by default
No grant means denied. Everything is a permission, even the baseline actions every signed-in user can do. Nothing is implicitly allowed.
One ambient context
The subject flows through CurrentAttributes from the gate to the smallest ViewComponent. The view reads the same resolver as the gate. No current_user threading.
The mounted UI at /current_scope
The mounted management UI is self-contained: light and dark, no web fonts, no build step, CSP-safe. The people who own the policy edit it there. Click the hero grid above to feel a toggle. These are the real screens.
Self-approve is blocked, even for full access.
Once an action is listed in config.sod_actions, the person who initiated the record cannot approve it. The veto sits first in the resolver. It is not a checkbox in the UI.
That is the four-eyes rule as a structural guarantee, not a convention in a policy class. How to turn it on →
Would-be denials — grant these to stop them: Ada Lovelace — currently Member 412x reports#index 38x reports#export Grace Hopper 7x reports#approve Total: 457 would-be denials across 2 subject(s).
One fixed decision order. Every check, everywhere.
There is exactly one path from “who is this and what do they want” to allow or deny. Controllers, views, and components all ask that same resolver, so the decision has a single source of truth.
Why the order matters
The veto sits first on purpose. Separation of duties is not a permission you can out-rank. A full-access owner who filed a report still cannot approve their own. It is not configurable in the UI, because it is a control, not a preference.
Everything below is additive: full access short-circuits, then org-wide, then per-record, then a record-less listed read. Miss all of them and you are denied. There is no implicit allow.
The veto is off by default. How to enable it, verify it is live, and when to break the glass.
Data you edit, or code you deploy?
Pundit, CanCanCan, and Action Policy are mature. CurrentScope is Beta. This table is about the model, not years in production.
| Question | CurrentScope | Pundit | CanCanCan | Action Policy |
|---|---|---|---|---|
| Where does the policy live? | Data. A grid you edit in the mounted UI. | Ruby policy classes | An Ability DSL in code | Ruby policy classes |
| Change what a role may do | Tick a cell. No deploy. | Edit code, review, deploy | Edit code, review, deploy | Edit code, review, deploy |
| Four-eyes / self-approve veto | Built in. Opt in by listing actions. Overrides full access. | You write it | You write it | You write it |
| Where the check lives | One ambient resolver via CurrentAttributes | You pass the user into each check | An Ability instance you build | Implicit authorization context |
| Where permissions come from | Your routes. New actions appear on the grid. | You name them in policies | You name them in Ability | You name them in policies |
| Per-record grants | Scoped roles as data | Policy scopes you write | Conditions in Ability | Policy scopes you write |
| Management UI | Mounted engine, light and dark | None | None | None |
| Production posture | Beta. Report mode first, then enforce. | Mature | Mature | Mature |
CurrentScope can sit beside a policy library if you want one. The engine stores the grants. A host policy can still ask allowed_to?. See the migration skill for Pundit, CanCanCan, and Action Policy.
Read the full comparison — including Banken and Oso, and when to choose them
Who it is for, and who it is not for.
Use it when the policy should be data
- A Rails team that changes roles every week, and is tired of shipping a deploy for each one.
- Approval or payroll workflows that need a four-eyes rule the author cannot override.
- Multi-tenant or per-record access: editor of this project, not of every project.
- Server-rendered Rails (Hotwire, ViewComponent) where one ambient check should drive the button and the gate.
Pick something else, or wait
- You want every permission change as a code review in git. Keep Pundit or Action Policy.
- Your rules depend on the record's data or the time ("only under 10,000"). That is a different library.
- Something outside Rails needs the same answer. This is a Rails engine, so it can only speak for the Rails app.
- You want the smallest possible dependency. This brings tables, a mounted UI, an audit ledger and a schema guard; Pundit is a convention and a few hundred lines.
- You cannot put beta software into production. The last gate before 1.0 is one real host running report mode, then enforcing.
- You need a first-class React, Next, or Inertia client contract. That is still open (limitations).
- You must flip
:enforceon a live host this week, with no report-mode bake. - You need rich ABAC beyond the SoD veto, or a multi-org IAM console.
Ask the same resolver, anywhere.
allowed_to? lives in controllers and views via one concern, and in any PORO or ViewComponent by mixing in CurrentScope::Permissions. The list-side question has an answer too.
# key derived from the record → reports#approve allowed_to?(:approve, report) # class form for collection actions allowed_to?(:create, Report) # explicit key when you need it allowed_to?("admin/reports#approve")
class ApproveButtonComponent < ViewComponent::Base include CurrentScope::Permissions def render? !report.approved? && allowed_to?(:approve, report) end end # the button asks the same resolver the gate does
# allowed_to? answers "may I act on THIS record?" # scope_for answers "WHICH records may I act on?" # — from the same roles, permissions, and scoped grants. def index @projects = scope_for(Project) end # the list and the per-record gate stay one source of truth
class ApplicationController < ActionController::Base include CurrentScope::Context # ambient subject from current_user include CurrentScope::Guard # fail-closed gate on every action end # opt out only where authz doesn't apply (sign-in, webhooks) class SessionsController < ApplicationController current_scope_skip_gate!(reason: "sign-in must run without a grant") # impersonation: also clear the mutation guard on sign-in/out skip_before_action :current_scope_mutation_guard! end
Mounted and gating in six steps.
# Gemfile gem "current_scope"
bin/rails generate current_scope:install bin/rails current_scope:install:migrations && bin/rails db:migrate
ApplicationController.include CurrentScope::Context # sets Current.user from current_user include CurrentScope::Guard # fail-closed gate on every action
class SessionsController < ApplicationController # Prefer the declared form so the role grid shows why the gate is off: current_scope_skip_gate!(reason: "sign-in must run without a grant") # bare skip_before_action :current_scope_check! still works (unexplained badge). # If you use impersonation, also skip the mutation guard on sign-in/out: skip_before_action :current_scope_mutation_guard! end
bin/rails current_scope:grant SUBJECT_ID=YOUR_USER_ID
/current_scope — the role grid, org-wide assignments, scoped grants. Guard denials are 403 with X-Current-Scope-Reason.Greenfield path above. Existing apps with users: run report mode before bootstrap/enforce, and read the security and production checklist before you ship. Full numbered guide: quickstart.html.
See a four-eyes refusal in a real Rails app.
The companion app is a full Rails 8.1 host: payroll, contracts, expenses, one-click act-as, and a guided “try to commit fraud, get refused” walkthrough. There is no hosted demo yet. Run it locally.
git clone https://github.com/davidteren/current_scope
git clone https://github.com/davidteren/current_scope_showcase
cd current_scope_showcase
bin/setup
bin/rails server # http://localhost:3000
Beta, with the security work in the open.
Built for experimentation and report-mode pilots while we harden the remaining security items. Use report mode in a real app. Do not call it generally available until #116 closes.
Security checklist
The pre-ship list: skipped controllers, 403 shape, impersonation, and the fail-closed promise.
Published advisory
GHSA-944r-4v99-qqf7. Grant ids as text, so UUID subjects cannot collapse. Fixed in 0.5.0.
Roadmap
What is built, what is still open, and why the last gate is a real-host bake.
Issue tracker
Security-relevant work is public. Open an issue if report mode shows a surprise.
Authorization your team can read.
A structural guarantee where you need one, an editable grid everywhere else. Add it to a Rails app, run report mode, and tell us what breaks.