Followup 5823e4e3b2,
this commit allows the addition of users along with
groups to access control lists, modifying DAccessControl
to support selecting a user or group from the same
search input.
Shown here is a mix of user & group permissions in the
`DAccessControl` component:
<img width="611" height="664" alt="image"
src="https://github.com/user-attachments/assets/c25e13b0-8885-4ce7-972c-5116f3acb094"
/>
When the search opens, we show the site's groups that
the user can see as preloaded values, showing only the
group name for clarity:
<img width="612" height="389" alt="image"
src="https://github.com/user-attachments/assets/df93a94b-918e-4fed-b045-ac72f02aca41"
/>
When searching a GET request is sent and users are included
in search results.
<img width="608" height="404" alt="image"
src="https://github.com/user-attachments/assets/ff17e6c0-2505-480e-ae25-e6f8309555bc"
/>
---------
Co-authored-by: Jordan Vidrine <jordan@jordanvidrine.com>
7.9 KiB
Vendored
Backend Model and Permission Lookups
Use this reference when working with AccessControlList, AclTarget, Acl::Target, Acl::User, Guardian, or target visibility queries.
Data Shape
AccessControlList stores one row per (target_type, target_id, permission).
Important columns:
target_type,target_id: polymorphic target.permission: freeform string such asview,edit,manage, or target-specific permissions.allowed_group_ids: bigint array of groups that hold the permission.allowed_user_ids: bigint array of users that hold the permission. Backend lookup and persistence support is partial; the main remaining gap is completeDAccessControluser editing.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.
Flattened vs Expanded ACLs
The frontend and service params use flattened entries:
[
{ type: "group", id: group.id, permission: "view" },
{ type: "user", id: user.id, permission: "edit" },
]
AccessControlList.expand_list_for_bulk_insert(list, target, owner) groups those entries by permission and returns rows suitable for insert_all!. It fills both allowed_group_ids and allowed_user_ids.
AccessControlList.where(target: target).flattened_list returns one entry per group or user per permission. Group entries include group metadata:
{
type: :group,
id: group.id,
permission: "view",
mandatory: false,
display_name: group.full_name.presence || group.name,
metadata: { auto_group: group.automatic? },
target_id: target.id,
target_type: target.class.polymorphic_name,
}
User entries use type: :user, display_name: user.display_name, and do not include group metadata.
Pass for_target: only when every row is for the same target; mixed targets raise Acl::MixedTargetError.
Matching Users and Groups
AccessControlList.matching_user(user) returns ACL rows applying to a user:
- Anonymous users match
Group::AUTO_GROUPS[:anonymous_users]. - Anonymous users also match
everyonewhileSiteSetting.granular_anonymous_and_logged_in_groups_permissionsis disabled. - Logged-in users match direct user rows,
user.belonging_to_group_ids, andlogged_in_users. - Logged-in users match
everyoneonly whileSiteSetting.granular_anonymous_and_logged_in_groups_permissionsis 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 and user lookups, then memoizes allowed_groups_preloaded and allowed_users_preloaded.
Lookup Objects
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])
target.permission_acl.user_has_permission?(user, "view")
target.permission_acl.user_has_any_permission?(user.id, %w[edit manage])
target.permission_acl.permission_user_ids("view")
target.permission_acl.user_ids_with_any_permission(%w[view edit manage])
permission_group_ids, group_ids_with_any_permission, permission_user_ids, and user_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.
Guardian Helpers
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.
AclTarget
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 byAccessControlList.where(target: self).target_acl(self)- reload cache clearing for
@permission_acl .with_acl_permission(guardian, permission).with_any_acl_permissions(guardian, permissions).acl_target_key, defaulting toname.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.
Mandatory and Banned ACLs
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.AccessControlListManagercalls that method before inserting rows.flattened_listmarks matching entries withmandatory: true.Site#access_controlexposes mandatory ACL metadata for registered targets.
Banned entries are consumed by both backend writes and frontend rendering:
AccessControlListManagerrejects submitted ACLs that match the target class'sbanned_aclvia thehas_no_banned_aclpolicy.Site#access_controlexposes banned ACL metadata for registered targets.DAccessControlfilters banned permission options for the matching grantee.
Keep mandatory and banned ACL metadata group-based unless the target flow has explicit user ACL UI and review coverage. Backend lookups can understand user ACL rows, but shared frontend editing is not complete.
Stale References
flattened_list is defensive around stale data:
- Unknown
target_typevalues are logged and skipped when nofor_target:is provided. - Deleted groups and users are skipped instead of raising while flattening.
This is a read-path fallback, not a substitute for validation. Validate group and user ids at the service/contract boundary, and rely on Jobs::CleanupAclsForDeleted to remove deleted grantee ids from persisted ACL rows.