.skills/discourse-acl-authoring/references/backend-model.md
Use this reference when working with AccessControlList, AclTarget, Acl::Target, Acl::User, Guardian, or target visibility queries.
AccessControlList stores one row per (target_type, target_id, permission).
Important columns:
target_type, target_id: polymorphic target.permission: freeform string such as view, edit, manage, or target-specific permissions.allowed_group_ids: bigint array of groups that hold the permission.allowed_user_ids: bigint array exists, but current authoring/UI support is incomplete.owner: string identifying the owning subsystem, usually "core" or a plugin name.The model has a uniqueness validation and DB index for target + permission. Multiple groups for the same permission collapse into one row.
The frontend and service params use flattened entries:
[
{ type: "group", id: group.id, permission: "view" },
{ type: "group", id: other_group.id, permission: "edit" },
]
AccessControlList.expand_list_for_bulk_insert(list, target, owner) groups those entries by permission and returns rows suitable for insert_all!.
AccessControlList.where(target: target).flattened_list returns one entry per group per permission, including group metadata:
{
type: :group,
id: group.id,
permission: "view",
mandatory: false,
name: group.name,
full_name: group.full_name,
metadata: { auto_group: group.automatic? },
target_id: target.id,
target_type: target.class.polymorphic_name,
}
Pass for_target: only when every row is for the same target; mixed targets raise Acl::MixedTargetError.
AccessControlList.matching_user(user) returns ACL rows applying to a user:
Group::AUTO_GROUPS[:anonymous_users].everyone while SiteSetting.granular_anonymous_and_logged_in_groups_permissions is disabled.user.belonging_to_group_ids, and logged_in_users.everyone only while SiteSetting.granular_anonymous_and_logged_in_groups_permissions is disabled.AccessControlList.matching_group(group) returns ACL rows granted directly to the group or directly to users in that group.
Use AccessControlList.preload_allowed before flattening relations that may include many rows. It batches group lookup and memoizes allowed_groups_preloaded; user preloading is still a TODO.
AccessControlList#target_acl(target) builds Acl::Target for a single target:
target.permission_acl.group_has_permission?(group, "view")
target.permission_acl.group_has_any_permission?(group.id, %w[edit manage])
target.permission_acl.permission_group_ids("view")
target.permission_acl.group_ids_with_any_permission(%w[view edit manage])
permission_group_ids and group_ids_with_any_permission return defensive array copies. Missing permissions return [], not nil.
AccessControlList#user_acl builds Acl::User for a user:
user.permission_acl.has_target_permission?(target, "view")
user.permission_acl.has_any_target_permission?(target, %w[edit manage])
user.permission_acl.target_ids_with_permission(TargetClass, "view")
user.permission_acl.target_ids_with_any_permissions(TargetClass, %w[view edit])
User#permission_acl and Guardian::AnonymousUser#permission_acl cache these lookups. Reload target records before reusing target.permission_acl after ACL writes.
Use the helper methods on Guardian instead of reaching into user.permission_acl from controllers:
guardian.has_acl_permission?(target, "view")
guardian.has_any_acl_permission?(target, %w[edit manage])
guardian.target_ids_with_acl_permission(TargetClass, "view")
guardian.target_ids_with_any_acl_permissions(TargetClass, %w[view edit manage])
Models that include AclTarget also get visibility scopes:
Board.with_acl_permission(guardian, "view")
Board.with_any_acl_permissions(guardian, %w[view edit manage])
Prefer these scopes for model index queries when they compose better with other filters. Use Guardian target-id helpers when the caller needs ids for more custom query construction.
Build domain-specific Guardian methods around those helpers:
def can_read_board?(board)
return true if board.anonymous_can_read?
return true if can_write_board?(board)
has_acl_permission?(board, "view")
end
def can_write_board?(board)
has_any_acl_permission?(board, %w[edit manage])
end
If a permission also requires a global gate, make that explicit in the domain method name/copy. For example, a site setting gate plus ACL manage is not the same as ACL manage alone.
Include AclTarget in any model that owns ACL rows:
class Board < ActiveRecord::Base
include AclTarget
end
The concern provides:
has_many :access_control_lists, as: :target, dependent: :destroypermission_acl, backed by AccessControlList.where(target: self).target_acl(self)@permission_acl.with_acl_permission(guardian, permission).with_any_acl_permissions(guardian, permissions).acl_target_key, defaulting to name.has_mandatory_acl?.acl_is_mandatory?(acl).has_banned_acl?.acl_is_banned?(acl)mandatory_acl_as_expanded_list(owner)AclTarget.acl_matches?(acl_a, acl_b) is the shared comparator for mandatory and banned ACL matching. It normalizes type to symbols and compares permission as strings.
Define self.mandatory_acl on the target class when some grants must always exist:
def self.mandatory_acl
[{ type: :group, id: Group::AUTO_GROUPS[:admins], permission: "manage" }]
end
Define self.banned_acl on the target class when specific grants must never be selectable or persisted:
def self.banned_acl
[{ type: :group, id: Group::AUTO_GROUPS[:anonymous_users], permission: "edit" }]
end
Mandatory entries are consumed by both backend writes and frontend rendering:
AccessControlList.inject_mandatory_acl(flattened_acl, target) appends missing mandatory entries.AccessControlListManager calls that method before inserting rows.flattened_list marks matching entries with mandatory: true.Site#access_control exposes mandatory ACL metadata for registered targets.Banned entries are consumed by both backend writes and frontend rendering:
AccessControlListManager rejects submitted ACLs that match the target class's banned_acl via the has_no_banned_acl policy.Site#access_control exposes banned ACL metadata for registered targets.DAccessControl filters banned permission options for the matching grantee.Keep mandatory and banned ACLs group-based unless user ACL support has been completed end-to-end.
flattened_list is defensive around stale data:
target_type values are logged and skipped when no for_target: is provided.This is a read-path fallback, not a substitute for validation. Validate group ids at the service/contract boundary, and rely on Jobs::CleanupAclsForDeleted to remove deleted group ids from persisted ACL rows.