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>::Authorisationby default, so an application namedShopwrites 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_leastcompares against, lowest role first. Taken from theauthkeyword 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 letrank_at_leastcroak per request, and that would be a 500 for something knowable at boot. A policy that never callsrank_at_leastsays so withrank => []. 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 oneauthtakes. Fromauthas well, and needed here for the same reasonrankis: 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.
grantedis 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)