0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-05 16:23:34 +08:00
discourse/.skills/discourse-acl-authoring/references/backend-model.md
Martin Brennan 7ff30d81a3
FEATURE: Support users in DAccessControl and backend (#41358)
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>
2026-07-15 09:18:06 +10:00

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 as view, 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 complete DAccessControl user 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 everyone while SiteSetting.granular_anonymous_and_logged_in_groups_permissions is disabled.
  • Logged-in users match direct user rows, user.belonging_to_group_ids, and logged_in_users.
  • Logged-in users match 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 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: :destroy
  • permission_acl, backed by AccessControlList.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 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.

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.
  • 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 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_type values are logged and skipped when no for_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.