agentleFS
izkrenyizkreny/agentifico/skills/action-policy-guru/SKILL.md

action-policy-guru

Skill4 starsChanged 14 days agoMITView on GitHub

How to write, review and test Action Policy authorization in a Ruby or Rails codebase: policy classes under app/policies, rules and default-deny, authorize!, allowed_to?, allowance_to, check?, allow! and deny!, pre-checks, rule aliases, authorization context, policy lookup, authorized_scope and scoping, verify_authorized hooks, failure reasons and their details, i18n messages, policy caching, and policy, request and scope specs. Use whenever a task touches a policy class, an authorization rule, a scope that filters records by permission, an ActionPolicy exception, or a spec that proves who may do what. Not for choosing an authorization library, and not for a codebase on Pundit, CanCanCan or model predicate methods, where this API does not exist; where a project documents its own authorization model, that document stays the authority on which answer is correct and this skill only covers how to express it.

The file

We split this skill into the rules it gives an agent

2 rules. About 0 are common, 0 are distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Every section names the upstream guide page it was distilled from, as upstream: <page>.

    line 17
  2. Very specific:

    Prefer letting inference work.

    line 166

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Action Policy

Action Policy answers one question, how to verify access, and deliberately answers no other. It ships no roles, no permissions and no model of who a user is, so every capability question in a codebase is that codebase's own; this skill covers the mechanism that expresses the answer (upstream: guide.md).

Paths inside this skill are relative to the skill's own directory.

Reading this skill against the source

Every section names the upstream guide page it was distilled from, as upstream: <page>. Those pages are embedded in assets/llms-full.txt, so checking any claim here against the source is one grep for the page's url: line, then reading forward:

grep -n "^url: .*/<page>$" <skill-dir>/assets/llms-full.txt

The prose is derived rather than copied: read it as a claim with a citation, not as upstream's own words.

A claim without a citation is this skill's own judgement rather than upstream's. A claim that disagrees with the cited page is a defect in this skill unless it names the gem version it was observed on, and an observed claim outranks the page. Code examples are restyled from upstream's, so an example never carries upstream's exact text either.

The snapshot is a pinned copy, and scripts/check-upstream.sh is what says whether it still matches the live docs.

The project's model wins

Where a repository documents who may do what, that document decides what a rule should return, and this skill decides how the rule is written. Read it before changing a rule or the spec that proves one, and treat a disagreement between it and the code as a defect in one of them rather than as licence to pick either.

References

Loaded when the task touches them, since each costs context on every load:

  • references/rails.md: controller and channel integration, the verify_authorized hooks, generators, config flags, rescuing the exception
  • references/scoping.md: authorized_scope, relation_scope, params_filter, named scopes, scope options, matchers
  • references/testing.md: policy specs and the RSpec DSL, authorization and scoping matchers, view specs, debugging a rule that answers wrongly
  • references/reasons.md: failure reasons, detailed reasons, result.all_details, i18n messages
  • references/caching.md: memoization layers, the rule cache store, invalidation, instrumentation events

For a question those files leave unanswered, grep assets/llms-full.txt for the page, as Reading this skill against the source shows, rather than loading the whole bundle.

The policy class

upstream: quick_start.md, writing_policies.md

One policy per resource, named for the singular resource with a Policy suffix, in <repo-root>/app/policies/, with rules named as the predicate form of the controller action they authorize.

class PostPolicy < ApplicationPolicy
  def show?   = record.published?
  def update? = user.admin? || record.user_id == user.id
end

record is the target being authorized and user is the authorization context. Both are readers on the policy, so a rule is an ordinary predicate method and needs no framework knowledge to read.

Rules must be public, because a private one is denied rather than refused loudly. On ActionPolicy::Base a private update? is not a rule at all: it falls to the default rule and returns false, the same path as a typo, so only the permitted-case spec under Deny by default, and the price of it catches it. It raises only under default_rule nil, as ActionPolicy::UnknownRule, or as a NoMethodError when an alias reaches it; observed on 0.7.7, where upstream says a private rule always raises (upstream: aliases.md).

Never instantiate a policy in application code. authorize! and allowed_to? add pre-checks, caching and reason tracking that PostPolicy.new(post, user:).update? skips silently, so a hand-built policy answers a different question from the one the request asks. A spec is the exception, and it calls apply(:update?) rather than the rule method for the same reason.

Deny by default, and the price of it

upstream: custom_policy.md, aliases.md

ActionPolicy::Base sets default_rule :manage? and defines manage? as false, so a rule the policy does not define resolves to manage? and is denied. An empty policy denies everything without a line of code.

A mistyped rule name is therefore a denial, not an error. update? misspelled resolves to manage? and returns false, which looks exactly like a considered refusal. Only a spec that asserts the permitted case catches it, which is why a rule proven solely by its denials is not proven. This is the trap references/testing.md is written around.

allowed_to?(:new, post) with the question mark missing hits the same path. Set ActionPolicy.enforce_predicate_rules_naming = true to make that shape raise instead. It does not catch a name the policy answers as a public method, such as :user, :record or a public helper, which is applied as a rule and allows on a truthy value; observed on 0.7.7.

Applying a policy

upstream: behaviour.md, writing_policies.md, quick_start.md, reasons.md

  • authorize! post, to: :update? raises ActionPolicy::Unauthorized on failure. The exception carries policy, rule and result, and result is where the reasons live.
  • allowed_to?(:edit?, post) returns true or false, and is what a view asks before rendering a link.
  • allowance_to(:edit?, post) returns the result object instead, for an API response that must say why rather than just no.
  • authorized_scope(Post.all) filters a relation through the policy; see references/scoping.md.

Inside a policy, a rule composes other rules with allowed_to?, passing a record for another policy's rule and omitting it for a rule on this one. check? wraps allowed_to? and takes the same arguments; it exists for readability, and reads better when the rule being composed lives on this policy. Prefer either over calling the predicate directly: only the wrapped call records a failure reason, so a rule composed with bare Ruby calls fails with no explanation of which part refused.

allow! and deny! end a rule early, and deny!(:no_user) names the reason while doing it.

class PostPolicy < ApplicationPolicy
  def show?
    allow! if user.admin?

    check?(:publicly_visible?)
  end
end

Authorization context

upstream: authorization_context.md

The context is everything a rule needs besides the record, and it is declared in two places that must agree: the policy says what it requires, the calling layer says where each value comes from.

class ApplicationPolicy < ActionPolicy::Base
  authorize :account
end

class ApplicationController < ActionController::Base
  authorize :account, through: :current_account
end

A missing or nil context value raises ActionPolicy::AuthorizationContextMissing. allow_nil: true permits a nil value with the key still required; optional: true permits the key to be absent.

Every policy that can be reached from another must share a context. A nested allowed_to? passes the calling policy's context to the nested one, so a policy requiring a key its caller does not have raises at the moment the composition runs rather than when it was written. Keeping the whole tree on one context set is what prevents that; a parent whose context is a superset of its children's also holds.

Pre-checks

upstream: pre_checks.md

A predicate repeated at the top of most rules belongs in a pre-check, which runs before the rule and can end authorization outright.

class PostPolicy < ApplicationPolicy
  pre_check :allow_admins

  def show? = record.published?

  private
    def allow_admins
      allow! if user.super_admin?
    end
end

A pre-check halts the moment it calls allow! or deny!, directly or through a helper, because both throw out of the check. Its return value is never read, so returning false does not deny, and a pre-check that calls neither lets the rule run; observed on 0.7.7, where upstream says the call's value must be returned.

Pre-checks take only: and except: like callbacks. skip_pre_check with only: removes an inherited one for the rules it names, whatever the inherited check was declared with, and with neither option removes it outright. With except:, which rules run the check afterwards, including ones it never ran for, depends on how the inherited check was declared and skipped before it, so prefer only:, and where except: is needed, assert in a policy spec which rules run the check. Observed on 0.7.7. Pre-checks come from ActionPolicy::Base, or from including ActionPolicy::Policy::PreCheck into a base policy written from scratch.

Aliases and the default rule

upstream: aliases.md

alias_rule :edit?, :destroy?, to: :update? resolves at authorization time rather than defining a method, which keeps the alias out of the specs: the rule is tested once and the alias is asserted to point at it. ActionPolicy::Base ships alias_rule :new?, to: :create?.

Resolution order for a rule on a subclass: a concrete method on the subclass, then a matching alias, then a concrete method up the ancestry, then the default rule. The alias step covers inherited aliases too: a subclass with no concrete destroy? of its own still resolves destroy? through an alias its superclass declared, ahead of any concrete superclass method. A subclass alias of the same name overwrites the inherited one rather than adding to it. That resolution order, overwrite included, was observed on 0.7.7.

Within one class, declaration order decides between a method and an alias of the same name. Defining a method deletes an alias already declared under its name, but an alias_rule declared after the method wins, so an alias line added below an existing concrete rule silently replaces it; observed on 0.7.7.

default_rule nil turns an unmatched rule into ActionPolicy::UnknownRule instead of a denial, which trades the safe direction for a loud one.

index? and create? are defined as false on ActionPolicy::Base and new? aliases to create?, so the default rule never sees them; routing them through it as well takes alias_rule :index?, :create?, :new?, to: :manage?.

Identifiers

upstream: writing_policies.md

CommentPolicy.identifier is :comment, and a namespaced policy's is :"admin/comment". The identifier is the key failure reasons are reported under and the scope i18n messages are looked up in, so renaming a policy moves both; self.identifier = :short_name pins it when the class name is unwieldy.

Policy lookup

upstream: behaviour.md, lookup_chain.md, namespaces.md

Lookup infers PostPolicy from a Post, and takes these overrides in order of how much they say: with: CustomPostPolicy names the class outright, namespace: Admin prefers Admin::PostPolicy and falls back, strict_namespace: true removes the fallback, and default: GuestPolicy catches a record with no policy at all. Without any of them a record with no policy raises ActionPolicy::NotFound.

Prefer letting inference work. An explicit with: in a controller is a claim that this action authorizes against a different policy than the record's own, which is worth reading and therefore worth being rare.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.