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".

flowchart TD R[root.1<br/>Global Configuration] --> C[com_tallpine<br/>component Options] C --> K[com_tallpine.category.12<br/>category Trails - Alpine] K --> T[com_tallpine.trail.22<br/>the trail report] X[access.xml<br/>declares the action names] -.-> C X -.-> K X -.-> T U[user groups and ancestors] --> Q{authorise core.edit<br/>com_tallpine.trail.22} T --> Q Q --> D[deny anywhere: no<br/>allow somewhere: yes<br/>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>

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:

  • name is the string passed to authorise(). The dotted core.* 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.

  • title is a language key shown as the row label of the permissions table; description is an optional key shown as a tooltip. Both keys must exist in the component’s administrator language files (see Language files and internationalisation). The common JACTION_* keys are provided by the core language file.

  • A section appears on a permissions tab only when a rules field with the matching section is placed on a form. A section that no form uses is harmless but invisible.

  • Only the component section may contain the administrative actions core.admin, core.options and core.manage. core.manage is what lets a user open the component in the administrator at all, and core.options lets 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 rules columns, 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>

The pieces that matter:

  • The fieldset is named permissions. Joomla’s Options screen (com_config) renders it as the last tab and gives its rules field the asset of the component, com_tallpine.

  • component names the access.xml to read and section the section to show. With the component section 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.admin or core.options on the component. A user holding only core.manage can 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;

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:

  1. 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 (when setRules() was called) to the asset, saves the asset, and finally writes the new asset_id back into the row. The asset_id is removed from the data of the row write itself, so it is only changed by this second step.

  2. delete() deletes the asset with the row’s name before deleting the row.

  3. 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 return com_tallpine.trail.22, the name that access.xml's trail section and every authorise() 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’s Content table 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);
    }
}

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;

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>

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;

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

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);
    }
}

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;
    }
}

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.
}

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() and publish() 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

com_tallpine

May this user open the component, create anywhere, change Options?

Category

com_tallpine.category.12

May this user create or publish in this branch of trails?

Item

com_tallpine.trail.22

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);

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);

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);
}

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;
    }
}

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 own access field 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 by core.login.api and 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.value action decides who may change a value (see Categories, custom fields and workflow integration);

  • workflow transitions use core.execute.transition on 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 access = this level.

com_tallpine Options → Permissions

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.

tallpine.close-trail on the component

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();
}

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 rules field lacks filter="rules" or the form lacks the hidden asset_id, or the table’s asset name differs from what authorise() receives. Read #__assets for the item’s asset and compare names.

  • The Permissions tab is missing. The fieldset is not named permissions, the user has neither core.admin nor core.options (Options), the form is for a new unsaved item, or access.xml is missing from the installed component.

  • Toolbar buttons are absent after packaging. access.xml is not listed in the manifest, so getActions() 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.own lets everyone edit. Ownership was not compared with created_by.

  • getAuthorisedViewLevels() compared with group ids. It returns level ids; use getAuthorisedGroups() for groups.

  • Rows with asset_id 0. 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 rules or the nested-set columns of #__assets by hand on a production site.

  • Keep Super User and core.admin rare, 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 JAccess, JFactory::getUser() and JFactory::getDbo(); the ACL model and access.xml format are the ones used today.

4.0

Namespaced components; user identity from the application with getIdentity(); core.login.api and core.execute.transition added to the action vocabulary.

4.2

CurrentUserTrait brings getCurrentUser() to models and views; Factory::getUser() is deprecated.

5.x

Same ACL model. Exceptions are enabled for the legacy setError() paths since 5.4.

6.0

AdminModel::getItem() returns stdClass, views lose $this->db, the CMS Input and Filesystem classes move to the backward-compatibility plugin, and the J* aliases are off by default on new installs. The Joomla\CMS\Access classes and access.xml are unchanged.

7.0 (announced)

ContentHelper::getActions() is announced to return a Registry; code that uses only get() is unaffected.

Where older books and tutorials are outdated

  • Developing Extensions for Joomla! 5 (2023) explains access.xml, the Permissions tab and the rules field correctly for namespaced components, and its model matches this page. Use the application-based getIdentity() instead of the static user accessor in code samples that fetch the user, and DatabaseAwareTrait with getDatabase() 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() and JAccess::getAuthorisedViewLevels(). The wiki pages mark themselves obsolete; on Joomla 6 the J* names work only with the backward-compatibility plugin, and the current approach is the application identity and the Joomla\CMS\Access\Access class.