Beta · report-mode pilots welcome · security roadmap

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.

Docs Showcase GitHub

$ bundle add current_scope

RubyGems version CI status MIT license

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).

Authorization editable

Edit role: Reviewer

One row per controller. Tick a cell. That is the policy, live on the next request.

controllerreadcreateupdatedestroyapprove
orders
reports
invoices
projects
expenses
Click a cell. This mock is local only.
A role is ticked cells on a grid. Click one. That is a deploy you did not ship.
The idea

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.

The usual way

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?
With CurrentScope

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.
What you get

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.

Visual proof

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.

permission grid · Content Editor
The permission grid: one row per controller, action columns, with ticked cells highlighted and a partial group shown indeterminate.
Permission grid. One row per controller, action groups derived from routes. Ticked cells glow. A partial group reads as indeterminate.
subjects · search
The subjects page: everyone who can hold a role, their org-wide role in a dropdown, and per-record scoped roles as chips.
Subjects and roles. Everyone who can hold a role, their org-wide role, and per-record scoped grants.
roles
The roles index listing editable roles.
Roles. Named bundles. Edit one, and the next request uses it.
members
The members view showing who holds a given role.
Members. Who holds a role org-wide, and who holds it on one record.
events
The append-only events ledger of authorization changes.
Events. An append-only ledger of UI grants, revokes, and role edits.
SoD veto · DENY

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 →

bin/rails current_scope:report
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).
The resolver

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.

1
SoD veto
Initiator of this record? (opt-in) — overrides everything
DENY
2
full_access
Role grants everything, present and future
ALLOW
3
org-wide role
Role’s permission set includes this action
ALLOW
4
scoped role
A role held on this record, or an ancestor role that ticks the key (opt-in; not scoped full_access)
ALLOW
5
record-less
No record: a scoped grant of the named type opens a listed collection read
ALLOW
6
otherwise
Nothing matched
DENY

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.

Comparison

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

Fit

Who it is for, and who it is not for.

A good fit

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.
Not a fit today

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 :enforce on a live host this week, with no report-mode bake.
  • You need rich ABAC beyond the SoD veto, or a multi-org IAM console.
In your code

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")
Quickstart

Mounted and gating in six steps.

Add the gem and install the engine.
# Gemfile
gem "current_scope"
Run the generator and migrations.
bin/rails generate current_scope:install
bin/rails current_scope:install:migrations && bin/rails db:migrate
Include the concerns in ApplicationController.
include CurrentScope::Context   # sets Current.user from current_user
include CurrentScope::Guard     # fail-closed gate on every action
Skip the gate where authorization does not apply — sign-in first, or nobody can log in. A skipped controller is unprotected: bring your own auth there.
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
Bootstrap the first full-access admin (the UI needs one to enter).
bin/rails current_scope:grant SUBJECT_ID=YOUR_USER_ID
Manage everything at /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.

Showcase

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
Status and trust

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.

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.