ワンクリックで
current-scope-migrate
Assisted migration from Pundit, CanCanCan, or Action Policy to current_scope (issue
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
Assisted migration from Pundit, CanCanCan, or Action Policy to current_scope (issue
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
| name | current-scope-migrate |
| description | Assisted migration from Pundit, CanCanCan, or Action Policy to current_scope (issue |
Contract (do not exceed it): by default this skill READS the host app and
WRITES only new files under docs/current_scope_migration/,
config/current_scope_parity.yml, lib/tasks/current_scope_migrate.rake,
and (phase 2) a reviewable migration under db/migrate/. It never guesses at
a rule the AST cannot prove. Two writes are explicit opt-ins, never
defaults: the --write call-site rewriter edits app code only when the
user asks for it (§9), and a generated backfill migration writes to the
database only when the team reviews its decision points and runs it (§10).
All three source systems are supported (#45 phases 1–3).
Run from the HOST app root. $SKILL_DIR below is this skill's directory.
bundle show pundit / bundle show cancancan /
bundle show action_policy (or Read Gemfile.lock). Pundit and Action
Policy keep policies in app/policies/; CanCanCan keeps
app/models/ability.rb. A host can run more than one — inventory each.bin/rails runner 'puts CurrentScope.catalog.keys.size' prints a nonzero count (routes must
be loaded for the catalog; a zero catalog means the engine is not mounted /
gated yet — point at the README install steps and stop).gem "prism").# Pundit and Action Policy (per-model policy classes):
ruby $SKILL_DIR/scripts/policy_inventory.rb app/policies > /tmp/cs_inventory.json
# CanCanCan (the Ability class):
ruby $SKILL_DIR/scripts/ability_inventory.rb app/models/ability.rb > /tmp/cs_ability.json
An Ability composed across files — merged abilities, or include SomeRules
modules whose files define further can rules — needs one run per file
(the script takes any path; a module file works when its class/module name
ends in Ability, otherwise wrap or inventory it by hand). Report the
composition in the decision report so nothing reads as a complete
single-file inventory.
policy_inventory.rb buckets every policy predicate, Scope#resolve, and
Action Policy relation_scope by what the AST
proves: pure_role / ownership / sod_shape / unparseable (its
--self-test documents the exact shapes), and flags Action Policy DSL
(pre_check, alias_rule, default_rule, authorize, scope_matcher)
for human folding.
ability_inventory.rb does the same for can/cannot rules: user-only
guards + no condition = pure_role (can :manage, :all = the full_access
shape), a record-column-vs-user hash = ownership, and everything else —
blocks, cannot (subtractive), attribute conditions, non-user guards —
lands in unparseable verbatim. When expanding CanCanCan actions to keys,
use its alias chain: :read → index/show, :create → new/create,
:update → edit/update, :manage → every routed action of the matching
controllers. A custom alias_action appears as its own inventory row —
expand the affected rules' actions through it by hand. Do not re-classify pure_role
or ownership by reading the code — the script's determinism is the point.
sod_shape is different: the AST proves the shape (a negated ownership
comparison on an approve-like name), not the intent — treat it as a
proposal that always needs human confirmation in the report, and note that
parity typically cannot verify SoD cells (they depend on the
initiator-record pairing), so SoD behavior is verified out-of-band with the
engine's own SoD test recipe.
Pundit rules are per-model; current_scope permissions are per-controller.
One PostPolicy may govern posts AND admin/posts. Build the mapping from
the same source the engine derives permissions from:
bin/rails runner 'CurrentScope.catalog.keys.group_by { |k| k.split("#").first }.each { |c, ks| puts "#{c}: #{ks.map { |k| k.split("#").last }.join(" ")}" }'
Match each policy's model to controller path(s) by route key (Post →
posts, admin/posts). Where a controller's model is not derivable from its
path (a DashboardController rendering Reports), record it — the parity
manifest's key_models: needs it.
For each unparseable entry, read the cited file:line and propose options
in the report — as judgment, clearly separated from the proved buckets:
record.published?, time windows, quotas):
current_scope is deliberately not ABAC. Options: keep as a plain guard in
the controller/model (head :forbidden unless @post.published?), or
restructure the workflow.edit? = update?): fold into the target's bucket.|| ownership): usually an
org-wide grant for the role PLUS a scoped role for the owner — propose the
pair, mark "review required".permitted_attributes: no current_scope equivalent — flag, out of scope.index_current_scope_one_role_per_subject). Detect the host's role shape
(users.role column? rolify? habtm roles?) and whether any user holds
several roles. Multi-role users need a declared precedence or a merged
role — a go/no-go finding at the top of the report, not a footnote.record.author == user is
intensional (all my posts, forever); a scoped role is extensional (explicit
grant rows). Migration needs a backfill (phase 2) plus a grant-on-create
hook, and grants become revocable data. State this in the report wherever
bucket ownership appears.Create docs/current_scope_migration/DECISION-REPORT.md:
pure_role rows: each distinct
user-predicate becomes a role; user.admin?-true-for-everything becomes
full_access: true; the permission keys come from §3's mapping.
Note: Role#permission_keys= rejects keys not in the routed catalog, so
seeding must run with routes loaded.ownership rows, each marked
review required with the semantic-shift note.sod_shape rows: propose
config.sod_actions = %w[<action>] + current_scope_initiator on the
model, linking the engine's SoD guide.unparseable row: file:line, verbatim
source, proposed options (§4).authorize @x →
delete (Guard gates it), policy(@x).update? / can? →
allowed_to?(:update, @x), policy_scope(X) → scope_for(X).skip_before_action :current_scope_check! and keep Pundit; migrated
controllers drop their authorize calls. Cut over controller by
controller; the parity task keeps both honest throughout.Copy the three templates (strip the .tt suffix). cp -n, never bare
cp — on a re-run, overwriting would wipe the team's filled-in manifest
and the accepted-diffs audit trail; if a target exists, leave it and say so:
cp -n $SKILL_DIR/templates/current_scope_migrate.rake.tt lib/tasks/current_scope_migrate.rake
cp -n $SKILL_DIR/templates/current_scope_parity.yml.tt config/current_scope_parity.yml
mkdir -p docs/current_scope_migration
cp -n $SKILL_DIR/templates/accepted_diffs.yml.tt docs/current_scope_migration/accepted_diffs.yml
Fill the manifest from what you learned: old_system: (pundit | cancancan |
action_policy — parity replays ONE system per run; a host running two
systems runs parity once per system, swapping the knob), one subject
exemplar per proposed
role (§6.2), one record exemplar per model with ownership rules,
scope_models: = every model whose policy defines Scope#resolve (from
§2's inventory — a listed model without an exemplar shows as "scope
UNVERIFIED" instead of silently passing), the key_models: overrides from
§3, and excluded_keys: for policies that need request context (they
cannot be replayed in-process — say so in the report).
Then run it and attach the first report:
bin/rails current_scope_migrate:parity || true # first run WILL diverge — that is the point
The task fails CI on any divergence not recorded (with a reason) in
accepted_diffs.yml. The team runs it from migration start until cutover.
Summarize: counts per bucket, the go/no-go verdict, where the report and harness live, and the explicit next steps (seed the proposed roles in report mode, work the human-decision list, keep parity green, then cut over per the partial-adoption recipe). Offer §9–§10 (rewrites and backfill) when the team is ready.
--write is the opt-in)Run report-only FIRST and put the JSON in the decision report:
ruby $SKILL_DIR/scripts/callsite_rewrite.rb app > /tmp/cs_rewrites.json
The rewriter's Pundit shapes: authorize @x deleted
only when every proof holds — statement position in a straight-line
def/block body, a side-effect-free argument (bare var/ivar/no-arg call),
alone on its line, not the file of a gate skip, and not a return value
(the last expression of anything except a public controller action);
policy(@x).update? → allowed_to?(:update, @x); and
policy_scope(X) → scope_for(X).
CanCanCan shapes: can?(:action, x) / cannot?(:action, x) →
allowed_to? / parenthesized negation (except :read/:manage, which
span multiple keys and are reviews); authorize! :action, @x deletes under
the same proofs PLUS the action naming the enclosing def (a cross-action
check is a review — deleting it would widen authorization);
Model.accessible_by(current_ability) → scope_for(Model) (any other
ability argument is a review — the subject may differ); controller macros
(load_and_authorize_resource etc.) are reviews pointing at the record
hook.
Action Policy shapes: allowed_to?(:rule?, x) strips the rule suffix
(options/extra args are reviews); authorize! @x, to: :rule? deletes under
the same proofs and enclosing-action match; authorized_scope(X.all) →
scope_for(X).
Everything failing any proof —
value-used/conditional/side-effect-arg authorize, custom query args,
permitted_attributes, every view-template occurrence
(ERB/Haml/Slim/jbuilder) — lands in reviews with file:line for a
human. Its --self-test (run in CI) pins those guarantees.
Only when the user explicitly asks to apply:
ruby $SKILL_DIR/scripts/callsite_rewrite.rb --write app
Then run the host's test suite and the parity task before committing
anything, and walk the reviews list by hand. Two caveats to state in the
report: policy(@x).edit?/new? rewrites keep those action names — the
gate enforces edit/new as their own keys, unlike Pundit's alias
convention, so check the decision report's key mapping for those; deleting
authorize relies on the controller being Guard-gated — confirm with
bin/rails current_scope:ungated first — and on the record hook loading
the SAME record the action targets (a file-local current_scope_record
is checked mechanically; hooks on a base controller are your assumption
to verify).
Detect the host's role shape (Read db/schema.rb and the subject model):
users.role) → copy
templates/backfill_enum_roles.rb.tt into
db/migrate/<timestamp>_backfill_current_scope_roles_from_enum.rb.
One role per user by construction — no precedence needed.rolify in Gemfile.lock, roles/users_roles tables) → copy
templates/backfill_rolify.rb.tt similarly. Its two preflights ABORT
before writing anything: multi-role users unresolved by PRECEDENCE
(the one-org-role-per-subject go/no-go from §5), and rolify
class-scoped roles (no current_scope equivalent — a human decision).After copying, replace the template's ActiveRecord::Migration[8.1] with
the host's own version tag (match what bin/rails generate migration emits
there — e.g. [8.2]), and name the copied file with a fresh timestamp.
Fill every DECISION-POINT constant from §6's proposed roles before handing
over. The migrations copy (never move) role data, grant through the
audited CurrentScope.grant! path for org-wide roles, and are deliberately
irreversible (the old columns/tables stay intact — rollback is "keep using
the old system"). Grid ticks still come from §6.2 seeding, not from the
backfill. Scoped-grant caveat: direct ScopedRoleAssignment writes are not
ledger-recorded (documented engine behavior) — the scoped audit trail
starts at cutover.