NAME

Punk::Plugin::Authorisation - may this user act on this row

SYNOPSIS

# lib/Shop.pm
auth model => 'User', rank => [qw(member admin owner)], roles => sub { ... };
plugin 'Authorisation';                       # rules: Shop::Authorisation

# lib/Shop/Authorisation.pm
package Shop::Authorisation;
use Punk::Plugin::Authorisation;              # installs `rule`

rule 'key.issue'  => sub { $_[0]->auth_id };
rule 'key.revoke' => sub {
    my ($c, $key) = @_;
    return 1 if $key && $key->{owner_id} == $c->auth_id;
    return $c->forbidden if $c->rank_at_least('admin');
    return $c->not_yours;
};
1;

# in a controller
my $key = $c->model('ApiKey')->get(id => $c->param('id'));
$c->may('key.revoke', $key) or return $c->deny;

DESCRIPTION

auth_guard answers may this user reach this route. An API key's scope answers may this credential call this operation. Neither can answer may this user act on this row, and that is where the commonest authorisation bug in a web application lives: the controller that loads a row by an id from the request and forgets to ask whose it is.

This plugin is the asking. The rules are the application's and live in one package it owns; what lives here is the machinery around them - collecting them, refusing to guess at a name nobody defined, and turning a refusal into the right status.

Every refusal is false

A rule returns true to allow. To refuse it returns a plain false value, or one of two helpers that are also false and say which refusal was meant:

return $c->forbidden;    # it exists, and it is not yours to touch  -> 403
return $c->not_yours;    # and do not confirm that it exists        -> 404

There is deliberately no truthy refusal. A convention where a rule returns -1 for "not enough rank" reads well and is a hole: -1 is true in Perl, so $c->may(...) or return $c->deny would allow it.

OPTIONS

policy

The package holding the rules; <AppClass>::Authorisation by default, so an application named Shop writes them in lib/Shop/Authorisation.pm and says nothing here. It is loaded at registration, and a package that defines no rules croaks.

rank

The ladder rank_at_least compares against, lowest role first. Taken from the auth keyword through $app->auth_config (Punk 0.32 and later), so an application on a Punk that provides it says nothing here.

Without a ladder the plugin croaks at to_app, naming both ways to give it one. It could instead let rank_at_least croak per request, and that would be a 500 for something knowable at boot. A policy that never calls rank_at_least says so with rank => [].

roles

The hook that says which roles the signed-in user holds, sub { my ($c, $user) = @_; ... }, returning a name, a list or an arrayref - the same one auth takes. From auth as well, and needed here for the same reason rank is: a Punk without $app->auth_config (before 0.32) cannot be asked for either, and a ladder with no hook answers "no" to everything.

A guard that has already run leaves its roles in $c->stash->{auth}{roles} and those are preferred; the hook is for every other request.

grants

The model holding runtime grants; off unless given. See "GRANTS".

fields

The grants model's column names: subject, action, object, granted_by, created.

HELPERS

may($action, @subject)

The rule for $action, as 0 or 1, called as $code->($c, @subject) in scalar context. An action no rule defines croaks, naming the actions that exist: a typo must never decide. Nothing is cached, because a rule reads rows this request may already have changed.

deny($why?)

The refusal. The status is the one the rule recorded through forbidden or not_yours; without one it is 404 when the check carried a subject and 403 when it did not, since a subject means a row whose existence is nobody's business. A 404 with no message goes through $c->not_found, so an application's on_not_found page answers it. Otherwise a browser (by Accept) gets a page and anything else the house {"errors":[...]} shape, as auth_guard's denial does. $why overrides the message, never the status.

forbidden / not_yours

False, and the reason deny reads.

rank_at_least($name)

Whether the signed-in user holds a role at or above $name on the ladder. A name that is not on the ladder croaks. Roles come from the auth keyword's own roles hook - the same answer auth_guard(role = ...)> acts on - or from $c->stash->{auth}{roles} when a guard has already loaded them.

GRANTS

plugin 'Authorisation' => { grants => 'Grant' };

rule 'doc.edit' => sub {
    my ($c, $doc) = @_;
    return 1 if $doc->{owner_id} == $c->auth_id;   # ownership first
    return 1 if $c->granted('doc.edit', $doc->{id});
    return $c->not_yours;
};

$c->grant('doc.edit', $doc->{id}, to => $user_id);
$c->revoke_grant('doc.edit', $doc->{id}, from => $user_id);

Off unless asked for, and worth three sentences before it is:

  • A grant table is a second source of truth wherever a rule already encodes ownership. Ownership belongs in the rule; grants are for what one user hands another.

  • granted is a query, per call. The rule above tests ownership first, so the request that did not need a grant does not pay for one.

  • Nothing is cached, because a revoked grant has to stop working at once.

With Punk-Sqitch installed the table ships as the Sqitch project punk_authz; the DDL is in Punk::Model::Grant for an application that manages its schema another way.

SEE ALSO

Punk::Auth, Punk::Plugin::APIKey, Punk::Authorisation.

AUTHOR

LNATION <email@lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION <email@lnation.org>.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)