ACL in Joomla Extensions
|
This content was generated with the assistance of AI and should be verified against the official Joomla documentation before being relied on in production. This sub-section’s Joomla bibliography lists the reference material consulted while preparing these pages. |
A Joomla component that stores content for more than one kind of person must answer two questions on its own, because the
framework does not answer them automatically: may this user do this? (action permissions on assets) and may this user
see this? (viewing access levels). This page shows how the Tallpine Hiking Club component com_tallpine declares its
actions in access.xml, exposes them in a Permissions tab, keeps an asset row for every trail report, and enforces
both questions in controllers, models, views and database queries. It was written on 2026-10-30 against Joomla 6.2.x and
PHP 8.3 or later. The ACL model itself is unchanged since Joomla 3, so the same code runs on the security-only 5.x line
unless a version note says otherwise.
The administrator screens, the permission calculator and the stored rule format are explained in Users, groups, access levels and ACL; this page assumes them and concentrates on the developer’s side. The component structure it plugs into is in Component development: the administrator MVC, and the broader checklist of input, output and request protection is in Security in extensions.
The model in one picture
Permissions are stored on assets. An asset is a row of #__assets with a name (com_tallpine,
com_tallpine.category.12, com_tallpine.trail.22), a parent and a JSON rules column. A component, a category and an
item each get their own asset, and the parents chain from the item up to the root root.1. The asset_id column of the
item’s own table points to its asset row. When code asks $user->authorise('core.edit', 'com_tallpine.trail.22'), the
access layer reads the rules of that asset and of every ancestor and applies the rule "an explicit deny wins, then an
explicit allow, otherwise no".
access.xml only declares which action names exist and where they appear. It stores no decision. The decisions live in
the rules column of the asset rows, written by the Permissions tabs. Viewing access levels are a separate mechanism:
an integer access column on each row and the list of level ids returned by $user->getAuthorisedViewLevels().
Declaring actions in access.xml
The file administrator/components/com_tallpine/access.xml lists the actions of the component in sections. A section
name is the second part of an asset name: the component section describes the asset com_tallpine, the category
section the assets com_tallpine.category.<id>, and a section named trail the assets com_tallpine.trail.<id>.
<?xml version="1.0" encoding="UTF-8"?>
<access component="com_tallpine">
<section name="component">
<action name="core.admin" title="JACTION_ADMIN" />
<action name="core.options" title="JACTION_OPTIONS" />
<action name="core.manage" title="JACTION_MANAGE" />
<action name="core.create" title="JACTION_CREATE" />
<action name="core.delete" title="JACTION_DELETE" />
<action name="core.edit" title="JACTION_EDIT" />
<action name="core.edit.state" title="JACTION_EDITSTATE" />
<action name="core.edit.own" title="JACTION_EDITOWN" />
<action name="tallpine.close-trail"
title="COM_TALLPINE_ACCESS_CLOSE_TRAIL_LABEL"
description="COM_TALLPINE_ACCESS_CLOSE_TRAIL_DESC" />
</section>
<section name="category">
<action name="core.create" title="JACTION_CREATE" />
<action name="core.delete" title="JACTION_DELETE" />
<action name="core.edit" title="JACTION_EDIT" />
<action name="core.edit.state" title="JACTION_EDITSTATE" />
<action name="core.edit.own" title="JACTION_EDITOWN" />
</section>
<section name="trail">
<action name="core.delete" title="JACTION_DELETE" />
<action name="core.edit" title="JACTION_EDIT" />
<action name="core.edit.state" title="JACTION_EDITSTATE" />
<action name="core.edit.own" title="JACTION_EDITOWN" />
<action name="tallpine.close-trail"
title="COM_TALLPINE_ACCESS_CLOSE_TRAIL_LABEL"
description="COM_TALLPINE_ACCESS_CLOSE_TRAIL_DESC" />
</section>
</access>
Source: Joomla Manual: ACL permissions
The core components follow the same shape. The source of com_banners has a component section with core.admin,
core.options, core.manage, core.create, core.delete, core.edit and core.edit.state, and a category section
with the last four. com_content adds core.edit.own, core.edit.value and the workflow actions to the component
section, and has an article section with only delete, edit and edit state, plus sections for field groups, fields,
workflows, transitions and stages.
The rules for writing the file are:
-
nameis the string passed toauthorise(). The dottedcore.*names are the framework’s vocabulary, which the permission screens, the toolbar helpers and core code understand. A custom action should be prefixed with the component or vendor (tallpine.close-trail) so that it never collides with a future core name. -
titleis a language key shown as the row label of the permissions table;descriptionis an optional key shown as a tooltip. Both keys must exist in the component’s administrator language files (see Language files and internationalisation). The commonJACTION_*keys are provided by the core language file. -
A section appears on a permissions tab only when a
rulesfield with the matchingsectionis placed on a form. A section that no form uses is harmless but invisible. -
Only the
componentsection may contain the administrative actionscore.admin,core.optionsandcore.manage.core.manageis what lets a user open the component in the administrator at all, andcore.optionslets a user change the component’s Options without being able to change its permissions. -
The file is read on demand. Adding an action in a new release needs no database migration: the new row appears in the permissions tabs at once, and every group is Inherited for it, which resolves to Not Allowed unless a parent allows it. Removing an action leaves its entries in old
rulescolumns, which are ignored.
The file must be listed in the manifest so that the installer copies it. In pkg_tallpine's component manifest it sits
next to the other administrator files:
<administration>
<files folder="administrator">
<filename>access.xml</filename>
<filename>config.xml</filename>
<folder>forms</folder>
<folder>language</folder>
<folder>services</folder>
<folder>sql</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
</administration>
If the file is missing from the package, or is not well-formed, ContentHelper::getActions() logs the error
JLIB_ERROR_COMPONENTS_ACL_CONFIGURATION_FILE_MISSING_OR_IMPROPERLY_STRUCTURED and returns an empty set of permissions,
so every toolbar button silently disappears. Check the manifest first when a freshly packaged component shows no buttons.
The Permissions tab of the component Options
Global Configuration offers permissions for the whole site, but a component should also expose its own Options →
Permissions tab. That tab is a normal field in administrator/components/com_tallpine/config.xml, using the rules
field type:
<?xml version="1.0" encoding="UTF-8"?>
<config>
<fieldset name="trails" label="COM_TALLPINE_CONFIG_TRAILS_LABEL">
<field name="default_difficulty" type="list" default="moderate"
label="COM_TALLPINE_CONFIG_DIFFICULTY_LABEL">
<option value="easy">COM_TALLPINE_DIFFICULTY_EASY</option>
<option value="moderate">COM_TALLPINE_DIFFICULTY_MODERATE</option>
<option value="hard">COM_TALLPINE_DIFFICULTY_HARD</option>
</field>
</fieldset>
<fieldset name="permissions"
label="JCONFIG_PERMISSIONS_LABEL"
description="JCONFIG_PERMISSIONS_DESC">
<field name="rules" type="rules"
label="JCONFIG_PERMISSIONS_LABEL"
validate="rules"
filter="rules"
component="com_tallpine"
section="component" />
</fieldset>
</config>
Source: Joomla Manual: ACL permissions
The pieces that matter:
-
The fieldset is named
permissions. Joomla’s Options screen (com_config) renders it as the last tab and gives itsrulesfield the asset of the component,com_tallpine. -
componentnames theaccess.xmlto read andsectionthe section to show. With thecomponentsection this tab lists the component-level actions. -
filter="rules"converts the posted array into the compact JSON stored in#__assets, dropping entries left at Inherited.validate="rules"checks that the submitter is allowed to change permissions. Omitting either is the most common reason for permissions that look saved but are not (see the pitfalls below). -
The Options screen itself requires
core.adminorcore.optionson the component. A user holding onlycore.managecan open the component but not its Options.
The component asset com_tallpine is created by the installer when the extension is installed, with the manifest’s
default rules (none, unless the manifest or an installer script adds them), and deleted when the extension is
uninstalled. It is the asset every category and item ends up under when no more specific parent exists.
Asset rows and the asset_id column
A table that should have per-row permissions needs three things: an asset_id column, a way to name its asset, and a
parent for it. The Tallpine install script creates the trail table with the columns the access layer relies on:
CREATE TABLE IF NOT EXISTS `#__tallpine_trails` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`asset_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'FK to #__assets',
`catid` int unsigned NOT NULL DEFAULT 0,
`title` varchar(255) NOT NULL DEFAULT '',
`alias` varchar(400) NOT NULL DEFAULT '',
`distance_km` decimal(6,2) NOT NULL DEFAULT 0,
`published` tinyint NOT NULL DEFAULT 0,
`access` int unsigned NOT NULL DEFAULT 0,
`created_by` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_catid` (`catid`),
KEY `idx_access` (`access`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Source: Joomla Manual: ACL permissions
access is the viewing access level (a row id of #__viewlevels), created_by the author used by core.edit.own, and
asset_id the link to the asset. The base Joomla\CMS\Table\Table class enables asset tracking when its constructor finds a
field named asset_id in the table. Its behaviour, from the source of the 5.4 line (the same class is used by 6.x), is:
-
store()writes the row, then computes the asset’s name, title and parent, loads the existing asset with that name, or creates a new one as the last child of the parent, copies the table’s rules (whensetRules()was called) to the asset, saves the asset, and finally writes the newasset_idback into the row. Theasset_idis removed from the data of the row write itself, so it is only changed by this second step. -
delete()deletes the asset with the row’s name before deleting the row. -
If the parent of an item changes (for example the trail moves to another category), the asset is moved under the new parent when the item is saved, so inherited permissions follow the move.
The default naming helpers are not what a component wants, so the table overrides them:
-
_getAssetName()defaults to the table name plus the primary key, which would produce#__tallpine_trails.22. Override it to returncom_tallpine.trail.22, the name thataccess.xml'strailsection and everyauthorise()call use. -
_getAssetTitle()defaults to the name. Return the item title, so that the assets are recognisable in the database. -
_getAssetParentId()defaults to the root asset. Return the asset id of the item’s category, falling back to the component asset, as core’sContenttable does for articles.
<?php
namespace Tallpine\Component\Tallpine\Administrator\Table;
\defined('_JEXEC') or die;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseInterface;
use Joomla\Database\ParameterType;
use Joomla\Event\DispatcherInterface;
/**
* Table for a trail report, with a per-row asset.
*/
class TrailTable extends Table
{
public function __construct(DatabaseInterface $db, ?DispatcherInterface $dispatcher = null)
{
// The column asset_id makes the base class track an asset for every row.
parent::__construct('#__tallpine_trails', 'id', $db, $dispatcher);
}
protected function _getAssetName(): string
{
return 'com_tallpine.trail.' . (int) $this->id;
}
protected function _getAssetTitle(): string
{
return $this->title;
}
protected function _getAssetParentId(?Table $table = null, $id = null): int
{
$parentId = null;
if ($this->catid) {
$db = $this->getDatabase();
$catId = (int) $this->catid;
$query = $db->createQuery()
->select($db->quoteName('asset_id'))
->from($db->quoteName('#__categories'))
->where($db->quoteName('id') . ' = :catid')
->bind(':catid', $catId, ParameterType::INTEGER);
$db->setQuery($query);
$parentId = (int) $db->loadResult();
}
// Fall back to the component asset (com_tallpine), then to the root asset.
return $parentId ?: (int) parent::_getAssetParentId($table, $id);
}
}
Source: Joomla Manual: ACL permissions
The base _getAssetParentId() returns the root asset, not the component asset, so a component that wants its component
asset as the default parent returns it explicitly, by loading the #__assets row named com_tallpine. Core tables such as
Content and Category follow this pattern: look up the category’s asset, and use the component’s asset when the category
has none. Whichever parent is chosen must be a valid asset id, otherwise the nested-set insert fails and store() returns
false.
Mismatches between the three methods are the main source of "permissions are ignored" reports. If _getAssetName() returns a
different string from what controllers pass to authorise(), the check finds no asset and falls back to whatever the
component asset says.
Rows created before asset tracking was added (or imported with SQL) have asset_id = 0. Detect them read-only and repair
them by loading and saving each item through its Table class, which creates the asset:
SELECT id, title
FROM `#__tallpine_trails`
WHERE asset_id = 0;
Source: Joomla Manual: ACL permissions
Never insert rows directly into #__assets. The table is a nested set, whose lft and rgt values must be consistent for
inheritance to work, and only the Asset table class maintains them.
Showing permissions on the item form
The edit form of a trail gets its own Permissions tab with the rules field for the trail section, plus a hidden
asset_id so that the field can find the item’s asset. In administrator/components/com_tallpine/forms/trail.xml:
<?xml version="1.0" encoding="UTF-8"?>
<form>
<fieldset name="details">
<field name="id" type="number" default="0" readonly="true" class="readonly" />
<field name="title" type="text" required="true" label="JGLOBAL_TITLE" />
<field name="catid" type="category" extension="com_tallpine" required="true"
label="JCATEGORY" />
<field name="access" type="accesslevel" label="JFIELD_ACCESS_LABEL" />
<field name="asset_id" type="hidden" filter="unset" />
</fieldset>
<fieldset name="permissions">
<field name="rules" type="rules"
label="JFIELD_RULES_LABEL"
filter="rules"
validate="rules"
component="com_tallpine"
section="trail" />
</fieldset>
</form>
Source: Joomla Manual: ACL permissions
filter="unset" on asset_id prevents a submitted value from ever reaching the table, so a tampered form cannot attach a
row to another item’s asset. The layout renders the fieldset in a tab:
<?php
\defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\HTML\HTMLHelper;
/** @var \Tallpine\Component\Tallpine\Administrator\View\Trail\HtmlView $this */
if ($this->canDo->get('core.admin') || $this->canDo->get('core.options')) :
echo HTMLHelper::_('uitab.addTab', 'myTab', 'permissions', Text::_('JGLOBAL_ACTION_PERMISSIONS_LABEL'));
echo $this->form->renderFieldset('permissions');
echo HTMLHelper::_('uitab.endTab');
endif;
Source: Joomla Manual: ACL permissions
How the rules field resolves what to display, from RulesField: it reads the actions of section from access.xml,
finds the asset id in the form field named by its asset_field attribute (default asset_id), and loads that asset’s rules.
For a new item there is no asset, so the field shows the parent’s calculated settings and the screen asks you to save
first. With no component attribute the field is in global mode and uses the root asset.
Checking permissions in code
Authorisation is always a call on the user object with an action and, ideally, the asset the action applies to.
<?php
$user = $this->getApplication()->getIdentity(); // inside a controller, dispatcher or view
$user->authorise('core.create', 'com_tallpine'); // component asset
$user->authorise('core.create', 'com_tallpine.category.12'); // one category
$user->authorise('core.edit', 'com_tallpine.trail.22'); // one trail
$user->authorise('tallpine.close-trail', 'com_tallpine.trail.22'); // custom action
$user->authorise('core.admin'); // global: Super User
Source: Joomla Manual: ACL access
Obtain the user from the application: $this->getApplication()->getIdentity() in classes that use the application
trait, $this->app->getIdentity() in controllers, $this->getCurrentUser() in models and views (provided by
CurrentUserTrait), and $app->getIdentity() for any other code that has an application. Do not use
Factory::getUser(). It is deprecated, it hides a dependency on global state, and the legacy JFactory alias works on
Joomla 6 only with the backward-compatibility plugin. A user other than the current one comes from the UserFactoryInterface
service, not from a static call.
The asset argument is a string, not an object. If the asset does not exist the check still runs against the nearest
asset that does: the lookup falls back through the parents, which is why a typing mistake in 'com_tallpine.trails.22'
quietly checks the component’s rules and does not raise an error. Define the asset names once, in a small helper, instead of
concatenating them in many places.
The asset-name helper
<?php
namespace Tallpine\Component\Tallpine\Administrator\Helper;
\defined('_JEXEC') or die;
use Joomla\CMS\Helper\ContentHelper;
/**
* Asset names and permission lookups for com_tallpine.
*/
final class AssetHelper
{
public const COMPONENT = 'com_tallpine';
public static function category(int $id): string
{
return self::COMPONENT . '.category.' . $id;
}
public static function trail(int $id): string
{
return self::COMPONENT . '.trail.' . $id;
}
/**
* Permissions for the toolbar: every action of the section as a boolean.
*/
public static function getActions(int $trailId = 0): object
{
return ContentHelper::getActions(self::COMPONENT, $trailId ? 'trail' : '', $trailId);
}
}
Source: Joomla Manual: ACL access
ContentHelper::getActions($component, $section, $id) builds the asset name as component when no section and id are
given, or component.section.id otherwise, reads every action of the component section of access.xml, and calls
authorise() for each one. It returns an object whose get('core.edit') gives a boolean, which is why toolbars use it to
decide which buttons to draw. Its return type is a CanDo object today and a change to a Registry is announced for
Joomla 7.0, so use only the get() method on the result. Note that it reads the component section’s actions, so any action
a toolbar needs must be listed there.
Enforcing permissions in the administrator
The toolbar decides what to show, but the controller and the model must decide what to allow, because a URL typed by hand
bypasses the toolbar. The base FormController and AdminModel provide overridable hooks for this; override them to check
the item-level asset instead of only the component.
<?php
namespace Tallpine\Component\Tallpine\Administrator\Controller;
\defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\FormController;
use Tallpine\Component\Tallpine\Administrator\Helper\AssetHelper;
class TrailController extends FormController
{
protected $text_prefix = 'COM_TALLPINE_TRAIL';
protected function allowAdd($data = [])
{
$user = $this->app->getIdentity();
$categoryId = (int) ($data['catid'] ?? $this->input->getInt('filter_category_id'));
if ($categoryId) {
// Creating inside a specific category
return $user->authorise('core.create', AssetHelper::category($categoryId));
}
// No category chosen yet: allowed when the user may create anywhere
return $user->authorise('core.create', 'com_tallpine')
|| \count($user->getAuthorisedCategories('com_tallpine', 'core.create')) > 0;
}
protected function allowEdit($data = [], $key = 'id')
{
$recordId = (int) ($data[$key] ?? 0);
$user = $this->app->getIdentity();
if (!$recordId) {
return parent::allowEdit($data, $key);
}
$asset = AssetHelper::trail($recordId);
if ($user->authorise('core.edit', $asset)) {
return true;
}
// core.edit.own only says the user MAY edit their own items; the code must check ownership
if ($user->authorise('core.edit.own', $asset)) {
$ownerId = (int) ($data['created_by'] ?? 0);
if (!$ownerId) {
$record = $this->getModel()->getItem($recordId);
$ownerId = (int) ($record->created_by ?? 0);
}
return $ownerId === (int) $user->id;
}
return false;
}
}
Source: Joomla Manual: ACL access
The model overrides two small methods that decide whether a record may be deleted or have its state changed:
<?php
namespace Tallpine\Component\Tallpine\Administrator\Model;
\defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\AdminModel;
use Tallpine\Component\Tallpine\Administrator\Helper\AssetHelper;
class TrailModel extends AdminModel
{
protected function canDelete($record)
{
// Only trashed trails can be deleted
if (empty($record->id) || (int) $record->published !== -2) {
return false;
}
return $this->getCurrentUser()->authorise('core.delete', AssetHelper::trail((int) $record->id));
}
protected function canEditState($record)
{
$user = $this->getCurrentUser();
if (!empty($record->id)) {
return $user->authorise('core.edit.state', AssetHelper::trail((int) $record->id));
}
if (!empty($record->catid)) {
return $user->authorise('core.edit.state', AssetHelper::category((int) $record->catid));
}
return parent::canEditState($record);
}
// getForm(), loadFormData() and the rest are shown in the component development page.
}
Source: Joomla Manual: ACL access
In the base AdminModel both methods check the component asset only, for example authorise('core.delete', $this->option),
so a component that wants per-category or per-item permissions must override them as above. AdminModel::getItem() returns a
stdClass on Joomla 6 (earlier versions returned CMSObject), so read properties directly, as in $record->created_by,
and do not call $record->get('created_by').
Two further rules keep the administrator safe:
-
Check the token as well as the permission. A permission answers "may this user", not "did this user mean to": state-changing tasks still call
$this->checkToken()(see Security in extensions). -
Check inside the model’s
save(),delete()andpublish()paths, not only in the view. The batch, drag-and-drop ordering and list-checkbox tasks reach the same code through other URLs than the edit form.
Item-level permissions in practice
Item-level permissions are an exception mechanism. A Trail Warden group may be allowed core.edit.state on the whole
Trails > Alpine category, while a single trail report is locked by denying core.edit to everyone but the board on that
one item.
| Level | Asset checked | Typical question |
|---|---|---|
Component |
|
May this user open the component, create anywhere, change Options? |
Category |
|
May this user create or publish in this branch of trails? |
Item |
|
May this user edit or delete this trail report? |
Because rules inherit downwards and an explicit deny cannot be overridden by a deeper allow, design permissions from the top:
allow at the broadest level that is acceptable, deny deliberately and rarely, and remember that a deny at the component
asset blocks the item. When a check unexpectedly fails, read the Calculated Setting column of the item’s permissions tab, which
shows the same result authorise() computes, including Not Allowed (Locked).
For lists, the question is usually "in which categories may this user do X?". getAuthorisedCategories() answers it in one
call, returning an array of category ids for the extension and action:
<?php
$user = $this->getCurrentUser();
$categories = $user->getAuthorisedCategories('com_tallpine', 'core.create');
// A filter drop-down or a query restricted to those categories
$query->whereIn($db->quoteName('a.catid'), $categories);
Source: Joomla Manual: ACL access
An empty array makes whereIn() produce a condition that matches nothing, which is the right outcome for a user with no
permission anywhere; do not special-case it by dropping the filter.
The core.edit.own owner check
core.edit.own is not enforced by the framework. It only means that the user may edit items that they created. The code
must compare the author with the user, as shown in allowEdit() above, and the same check belongs in the site-side edit
view. The corresponding front-end pattern is:
<?php
$canEdit = $user->authorise('core.edit', 'com_tallpine.trail.' . $item->id)
|| ($user->authorise('core.edit.own', 'com_tallpine.trail.' . $item->id)
&& (int) $item->created_by === (int) $user->id);
Source: Joomla Manual: ACL access
A guest has user id 0, and an item whose created_by is 0 (for example one imported without an author) would then match
a guest who holds core.edit.own. Guests normally cannot hold the permission, but also test !$user->guest when the
component allows anonymous submissions.
Viewing access levels in code and queries
The access column answers "may this user see it?", and it has to be enforced in every place that reads the data. The
Manual’s guidance is blunt: Joomla does not apply these checks automatically, and the extension must call them. The
method is $user->getAuthorisedViewLevels(). It returns an array of level ids, not group ids, although some core code
names its variable $groups.
For one item that a view or model has loaded:
<?php
use Joomla\CMS\Language\Text;
$user = $this->getCurrentUser();
if (!$user->authorise('core.admin') && !\in_array((int) $item->access, $user->getAuthorisedViewLevels(), true)) {
// A guest may be sent to the login screen; a logged-in user gets 403
throw new \Exception(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
Source: Joomla Manual: ACL access
Checking core.admin as well lets Super Users see everything, which is what the Manual suggests. The same item check must
also cover the item’s category: an article whose own level is Public is still hidden when its category is Registered. The
site model therefore joins the category and tests both columns.
For a list, put the levels in the query. It is cheaper than loading and testing each row, and it makes paging and counting correct:
<?php
namespace Tallpine\Component\Tallpine\Site\Model;
\defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\Database\QueryInterface;
class TrailsModel extends ListModel
{
protected function getListQuery(): QueryInterface
{
$db = $this->getDatabase();
$user = $this->getCurrentUser();
$query = $db->createQuery();
$query->select($db->quoteName(['a.id', 'a.title', 'a.alias', 'a.catid', 'a.distance_km', 'a.access']))
->from($db->quoteName('#__tallpine_trails', 'a'))
->join('LEFT', $db->quoteName('#__categories', 'c'), 'c.id = a.catid');
if (!$user->authorise('core.admin')) {
// Item and category must both be at a level the user can see
$levels = $user->getAuthorisedViewLevels();
$query->whereIn($db->quoteName('a.access'), $levels)
->whereIn($db->quoteName('c.access'), $levels);
}
// Unpublished items only for people who can edit
if (!$user->authorise('core.edit.state', 'com_tallpine') && !$user->authorise('core.edit', 'com_tallpine')) {
$query->where($db->quoteName('a.published') . ' = 1');
}
return $query;
}
}
Source: Joomla Manual: ACL access
whereIn() binds one parameter per value and its default type is integer, so the id list needs no manual quoting or
casting. The query builder is explained in Database API. The same pattern appears in
core’s article and contact list models, and in the Smart Search finder adapters for content the index must not leak.
Other places that need the access check, because they bypass the list model:
-
modules (for example
mod_tallpine_nexthikes): a module’s helper selects data itself and must filter by levels itself, even though the module’s ownaccessfield already controls who sees the module; -
the Web Services API: a controller’s
displayList()/displayItem()and custom routes run a model that must apply the same filters, because the API user is authorised only bycore.login.apiand the permissions of their groups (see Extending the Web Services API); -
search and feed code, including the Smart Search plugin
plg_finder_tallpine, which indexes the item’s access level and filters results by it; -
custom fields on trails: the field and field-group assets follow the same rules, and the
core.edit.valueaction decides who may change a value (see Categories, custom fields and workflow integration); -
workflow transitions use
core.execute.transitionon the workflow’s assets (see Workflows).
Worked example: Trail Warden conditions
The Tallpine club wants wardens to update the conditions of trails in the Trails > Alpine branch, ordinary members to read them, and visitors to see only published, public trails. Combining the pieces above gives the configuration:
| Where | Setting | Effect |
|---|---|---|
Users → Access Levels |
Level Trail Wardens, group Trail Warden |
Wardens-only content has |
|
Trail Warden: Access Administration Interface Allowed |
The group can open the component. |
Category Trails > Alpine → Permissions |
Trail Warden: Edit, Edit State and Create Allowed |
Only inside this category. |
Category Trails > Alpine → Permissions |
Trail Warden: Delete Denied |
Wardens cannot delete there, whatever a parent allows. |
One trail → Permissions |
Registered: Edit Own Allowed |
Members may edit a trail they created. |
|
Trail Warden: Allowed |
Only wardens may mark a trail closed. |
The "mark closed" action is enforced in the model method that the button calls:
<?php
use Joomla\CMS\Language\Text;
public function close(int $trailId): bool
{
$user = $this->getCurrentUser();
if (!$user->authorise('tallpine.close-trail', AssetHelper::trail($trailId))) {
$this->setError(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'));
return false;
}
$table = $this->getTable();
if (!$table->load($trailId)) {
return false;
}
$table->closed = 1;
return $table->store();
}
Source: Joomla Manual: ACL access
Joomla 5.4 enabled exceptions for setError()-style failure paths and core drops the setError() calls in 7.0, so new code
should throw an exception and let the controller turn it into a message, as described in
Error handling and legacy APIs. The example uses setError() only to
stay close to the AdminModel convention that list and form controllers read today.
Plugins and ACL
A plugin that observes content events (for example plg_content_tallpine_conditions) runs for every user, including
guests, so it must not assume a permission. Read the user from the event or the application, check the same assets the
component does, and never expose protected fields through onContentPrepare output. The Manual’s ACL pages also describe
the core.admin shortcut: Super Users pass every authorise() check, so plugins that need to treat even them as ordinary
users (for example in a frontend preview) must test group membership instead.
Common errors and pitfalls
-
Permissions appear to save but do not take effect. The
rulesfield lacksfilter="rules"or the form lacks the hiddenasset_id, or the table’s asset name differs from whatauthorise()receives. Read#__assetsfor the item’s asset and compare names. -
The Permissions tab is missing. The fieldset is not named
permissions, the user has neithercore.adminnorcore.options(Options), the form is for a new unsaved item, oraccess.xmlis missing from the installed component. -
Toolbar buttons are absent after packaging.
access.xmlis not listed in the manifest, sogetActions()finds nothing. -
A guest sees protected items. The list query or item loader does not test
access; hiding the menu item does not protect the URL. Test both the item and its category, and return 403 or the login redirect. -
core.edit.ownlets everyone edit. Ownership was not compared withcreated_by. -
getAuthorisedViewLevels()compared with group ids. It returns level ids; usegetAuthorisedGroups()for groups. -
Rows with
asset_id0. The items were inserted by SQL or before tracking was enabled; they inherit from the component and cannot have item permissions until saved through their Table class. -
Checking only the component.
authorise('core.edit', 'com_tallpine')ignores category and item denies; pass the asset of the object that is being edited. -
Using the permission as a CSRF defence. Always check the form token as well.
-
Cached pages. A cached page rendered for one group can be served to another when a list is cached without the user’s group in the cache key. Cache with the access levels as part of the key, or do not cache user-specific views (see Caching and performance).
Security summary
-
Enforce on the server, in the controller and model, using the item’s asset. A hidden button or menu item is a convenience.
-
Default to deny. Do not invent "allow if the action is unknown" fallbacks; the access layer’s own default for no match is deny.
-
Keep custom actions few and named after the component, and describe them so administrators know what they grant.
-
Never edit
rulesor the nested-set columns of#__assetsby hand on a production site. -
Keep Super User and
core.adminrare, and protect those accounts with multi-factor authentication (see Security hardening, MFA and passkeys). -
Filter lists with
getAuthorisedViewLevels()in the query, not after loading.
Version notes
| Version | Note |
|---|---|
3.x and earlier |
Components called |
4.0 |
Namespaced components; user identity from the application with |
4.2 |
|
5.x |
Same ACL model. Exceptions are enabled for the legacy |
6.0 |
|
7.0 (announced) |
|
Where older books and tutorials are outdated
-
Developing Extensions for Joomla! 5 (2023) explains
access.xml, the Permissions tab and therulesfield correctly for namespaced components, and its model matches this page. Use the application-basedgetIdentity()instead of the static user accessor in code samples that fetch the user, andDatabaseAwareTraitwithgetDatabase()instead of a property or the container lookup of the database in list queries. -
Joomla! 4 Masterclass (2023) describes the permission screens and groups as they exist on 4.x; the model is unchanged on 5.x and 6.x, and the screens are described on Users, groups, access levels and ACL.
-
Wiki tutorials from the 2.5 and 3.x era write
JFactory::getUser()->authorise()andJAccess::getAuthorisedViewLevels(). The wiki pages mark themselves obsolete; on Joomla 6 theJ*names work only with the backward-compatibility plugin, and the current approach is the application identity and theJoomla\CMS\Access\Accessclass.