Koha has no first-class hook for plugins to register their own staff permissions. The pattern below works today by writing directly into Koha's permissions table under module_bit = 19 (the plugins module), then checking grants via C4::Auth::haspermission. It is a workaround — a native registration hook would remove every caveat in this document.
This pattern is implemented end-to-end in koha-plugin-staff-roster.
- No hook to declare permissions. Plugins must
INSERTinto a core table. - No core machinery to render labels for plugin codes.
koha-tmpl/.../includes/permissions.incuses a hardcodedSWITCH/CASEover known codes; unknown (plugin) codes render an empty<label>. Plugins must inject labels viaintranet_js. - No lifecycle integration. Install, upgrade, and uninstall must each manage the rows manually, and
REPLACE INTOis unsafe because of cascading deletes. - No UI grouping. Plugin codes appear flat under the "plugins" module alongside every other plugin's codes.
Two tables, both keyed on (module_bit, code):
| Table | Role |
|---|---|
permissions |
Catalogue of available permissions. One row per (module_bit, code) describes what the permission means. |
user_permissions |
Grants. One row per (borrowernumber, module_bit, code) says "this staff member has this permission". |
module_bit = 19 is the plugins module. Three flag states matter:
flags = 1— superlibrarian, bypasses every check.flags & (1 << 19)set — wholesale "plugins" permission. Implies every plugin sub-permission.flags & (1 << 19)unset,user_permissionsrow exists — limited mode. The user has only the listed plugin sub-permissions.
A grant in user_permissions only takes effect when the user is in limited mode for that module (the module bit in flags is not set). Wholesale grants short-circuit the sub-permission check.
Keep the catalogue in one place so install, upgrade, and uninstall see the same set:
my %SUBPERMISSIONS = (
staffroster_view => 'Staff Roster: view rosters and own schedule',
staffroster_assign => 'Staff Roster: drag staff onto slots and edit assignments',
staffroster_manage_rosters => 'Staff Roster: create or edit rosters, slots, exceptions',
# ...
);Code conventions:
- Prefix every code with your plugin's slug (
staffroster_*) — the global namespace under module 19 is shared with every other installed plugin. - Use snake_case. Koha's UI is built around that style.
- Keep the description short; it appears next to a checkbox on the staff member's "Set permissions" page.
Per Koha coding guideline PERL10, helpers reach for C4::Context->dbh themselves rather than accepting $dbh through the signature.
sub _register_permissions {
my $dbh = C4::Context->dbh;
# Upsert rather than REPLACE: the latter does DELETE + INSERT, which
# cascade-clobbers any existing user_permissions grants for the same
# (module_bit, code) every time the plugin upgrades.
for my $code ( sort keys %SUBPERMISSIONS ) {
$dbh->do(
q{INSERT INTO permissions (module_bit, code, description)
VALUES (19, ?, ?)
ON DUPLICATE KEY UPDATE description = VALUES(description)},
undef, $code, $SUBPERMISSIONS{$code}
);
}
return;
}Critical:
REPLACE INTO permissionsis unsafe. Theuser_permissionsforeign key cascades onDELETE, so aREPLACEagainst the catalogue silently wipes every grant for that code. UseINSERT ... ON DUPLICATE KEY UPDATEso only the description changes.
Wire it into both lifecycle hooks so existing installs pick up new codes and description tweaks without manual intervention:
sub install {
my ($self) = @_;
# ... your CREATE TABLEs ...
_register_permissions();
return 1;
}
sub upgrade {
my ($self) = @_;
# ... version-gated migrations ...
_register_permissions();
$self->store_data({ '__INSTALLED_VERSION__' => $self->get_metadata->{version} });
return 1;
}Drop both the catalogue rows and any grants. Without the second statement, removing the plugin and reinstalling can leave dangling user_permissions rows pointing at codes whose descriptions have drifted.
sub _unregister_permissions {
my @codes = keys %SUBPERMISSIONS;
return if !@codes;
my $dbh = C4::Context->dbh;
my $placeholders = join q{,}, ('?') x @codes;
$dbh->do(
qq{DELETE FROM permissions WHERE module_bit = 19 AND code IN ($placeholders)},
undef, @codes
);
$dbh->do(
qq{DELETE FROM user_permissions WHERE module_bit = 19 AND code IN ($placeholders)},
undef, @codes
);
return;
}Every protected handler should funnel through one helper. Bypass for superlibrarians (flags == 1 or the lowest bit set) matches Koha's convention everywhere else.
sub _has_perm {
my ($code) = @_;
my $env = C4::Context->userenv;
return 0 if !$env;
my $flags = $env->{flags} // 0;
return 1 if $flags == 1 || ( $flags & 1 );
require C4::Auth;
return C4::Auth::haspermission( $env->{id}, { plugins => $code } ) ? 1 : 0;
}
sub _gate {
my ( $code, $messages ) = @_;
return 1 if _has_perm($code);
push @{$messages}, { type => 'danger', code => 'access_denied' };
return 0;
}haspermission accepts { plugins => $code } and returns truthy when the user is either:
- A superlibrarian, or
- Granted
pluginswholesale (module bit 19 set inflags), or - In limited mode and has a
user_permissionsrow for that exact(19, $code).
Use the gate at the top of each action:
sub _admin_save_type {
my ( $cgi, $id, $messages ) = @_;
return if !_gate( 'staffroster_manage_types', $messages );
my $dbh = C4::Context->dbh;
# ... privileged work ...
}x-koha-authorization in openapi.json (see plugin-rest-api.md) gates the JSON endpoints. The rendered HTML shell that wraps them is a separate surface — a user with Koha's generic tools flag can reach plugins/run.pl?...&method=tool&op=manage_swaps even when the underlying JSON calls would 403. Two surfaces, two gates needed.
Map each tool op to its required sub-permission and gate before the view renderer runs. Visibility checks come second; falling back to list on access denied keeps the breadcrumb stable:
sub tool {
my ( $self, $args ) = @_;
my $cgi = $self->{cgi};
my $op = $cgi->param('op') // 'list';
my @messages;
# Sub-permission gates parallel to the REST layer's checks. The CGI
# views previously only relied on Koha's generic `tools` flag, which
# let any tools-flagged user reach the rendered shell even when the
# JSON endpoints behind it would 403. Gate first, then proceed.
state $perm_for_view = {
manage_slots => 'staffroster_manage_rosters',
manage_exceptions => 'staffroster_manage_rosters',
manage_swaps => 'staffroster_manage_rosters',
edit_roster => 'staffroster_manage_rosters',
add_roster => 'staffroster_manage_rosters',
delete_confirm => 'staffroster_manage_rosters',
view_assignments => 'staffroster_view',
list => 'staffroster_view',
my_shifts => 'staffroster_view',
open_shifts => 'staffroster_self_assign',
};
if ( my $required = $perm_for_view->{$op} ) {
if ( !Koha::Plugin::...::Lib::Permissions::has_perm($required) ) {
push @messages, { type => 'danger', code => 'access_denied' };
$op = 'list';
}
}
# Visibility gate for ops that scope to a specific roster.
state $roster_scoped_ops = { map { $_ => 1 }
qw(edit_roster manage_slots manage_exceptions manage_swaps view_assignments delete_confirm) };
if ( $roster_scoped_ops->{$op} && ( my $rid = $cgi->param('roster_id') ) ) {
my $roster = $dbh->selectrow_hashref(
q{SELECT * FROM staff_roster WHERE id = ?}, undef, $rid);
if ( !Koha::Plugin::...::Lib::Visibility::can_view_roster( $self, $roster ) ) {
push @messages, { type => 'danger', code => 'access_denied' };
$op = 'list';
}
}
# ... renderer dispatch ...
}Audit the surfaces in pairs: every REST route's x-koha-authorization should have a corresponding entry in the CGI $perm_for_view map for the op that hosts the JS that calls it. Drift between the two is the failure mode this catches.
Koha's members/member-flags.pl page renders sub-permissions through permissions.inc, which has a hardcoded [% SWITCH name %] block over the codes it knows about. A plugin code lands in the [% CASE %] (default) branch, which emits an empty <label>. The checkbox renders, the user can toggle it, but there is no description text next to it.
Inject labels client-side via intranet_js. The page id on current Koha main is pat_member-flags (newer staff theme work has used patrons_member-flags in places — the JS below matches both for safety).
sub intranet_js {
my $self = shift;
return <<~'JS';
<script>
(function () {
var page = document.body && document.body.id;
if (page !== 'patrons_member-flags' && page !== 'pat_member-flags') return;
var labels = {
staffroster_view: 'Staff Roster: view rosters and own schedule',
staffroster_assign: 'Staff Roster: drag staff onto slots and edit assignments',
staffroster_manage_rosters: 'Staff Roster: create or edit rosters, slots, exceptions'
// ...keep in sync with %SUBPERMISSIONS
};
// Sub-permission checkboxes carry value="<flag>:<code>"
// (e.g. "plugins:staffroster_view") and id "<flag>_<code>".
// Strip the prefix before looking up the label map.
document.querySelectorAll('input.flag[type="checkbox"][name="flag"]').forEach(function (cb) {
var raw = cb.value || '';
var code = raw.indexOf(':') >= 0 ? raw.split(':')[1] : raw;
if (!Object.prototype.hasOwnProperty.call(labels, code)) return;
var label = document.querySelector('label[for="' + cb.id + '"]')
|| (cb.closest('li, tr, div') || document).querySelector('label.permissiondesc');
if (label && !label.textContent.trim()) {
label.innerHTML = '<span class="sub_permission">' + labels[code] +
'</span> <span class="permissioncode">(' + code + ')</span>';
}
});
})();
</script>
JS
}Notes:
- The checkbox
valueis"plugins:<code>"— strip theplugins:prefix before lookup. A previous iteration of this pattern matched on the raw value and silently failed. - Only overwrite labels that are blank (
!label.textContent.trim()). Future Koha releases may learn to render plugin codes natively; bailing on populated labels avoids fighting them. - Inject only on the flags page.
intranet_jsruns on every staff page, so always gate bydocument.body.id.
Sites tend to fall into two camps:
- Wholesale: staff who can use plugins get the
pluginsmodule bit and inherit every sub-permission your plugin defines, including any new ones added in later versions. Good default for trusted teams. - Limited: each staff tier gets exactly the codes they need (
staffroster_viewfor everyone,staffroster_swap_approveonly for managers, etc.). Choose code names so the rollup makes sense — e.g. split read/write/manage rather than per-button.
Document which is intended in your plugin's README; otherwise sites will pick the wrong mode and either over-grant or break workflows.
| Pitfall | Symptom | Fix |
|---|---|---|
Using REPLACE INTO permissions |
Grants silently disappear after upgrade | Use INSERT ... ON DUPLICATE KEY UPDATE |
Not re-running registration in upgrade |
New codes from a later release never appear | Always call your _register_permissions from both install and upgrade |
| Matching JS labels against raw checkbox value | Labels never appear | Strip the plugins: prefix before lookup |
Forgetting superlibrarian bypass in _has_perm |
Admins get "access denied" on their own plugin | Short-circuit flags == 1 or flags & 1 |
Skipping _gate on a single CRUD action |
A non-privileged user can hit the action via direct URL | Gate every action handler, not just the rendering layer |
Leaving user_permissions rows on uninstall |
Reinstall surfaces stale grants tied to old descriptions | Delete from both permissions and user_permissions in uninstall |
If Koha grew first-class plugin permission support, these would all collapse into hook output:
- A
permissionshook returning{ code => description }so plugins never touch SQL. - Automatic registration on install/upgrade and cleanup on uninstall.
- Label rendering in
permissions.incvia the same hook output, removing theintranet_jsworkaround entirely. - Per-plugin grouping headers on the flags page.
- Optional
module_bitnamespacing per plugin to avoid the shared global namespace.
The implementation above is forward-compatible: when a native hook lands, dropping the SQL helpers and the JS injector while keeping the _has_perm / _gate gates leaves all callers untouched.
members/member-flags.plandkoha-tmpl/intranet-tmpl/prog/en/includes/permissions.inc— where label rendering lives in core.C4::Auth::haspermission— runtime check used by every staff page.koha-plugin-staff-rosterStaffRoster.pm— full working implementation (search for_register_permissions,_has_perm,intranet_js).