Class: Collavre::Creatives::PermissionFilter

Inherits:
Object
  • Object
show all
Defined in:
app/services/collavre/creatives/permission_filter.rb

Overview

Batch "which of these creative ids can the user read?" using the O(1) CreativeSharesCache table. Extracted from FilterPipeline so the picker's has_children presence check can apply the exact same permission posture as the browse endpoint (children_with_permission) without re-deriving it.

A user-specific cache entry is authoritative over a public share: it grants only when its own rank meets the requested threshold and otherwise suppresses the public share (so a no_access — or any below-threshold user entry — wins over a more permissive public share). Owned creatives are always readable.

Constant Summary collapse

OWNER_RANK =

An owner always resolves to admin, the top rank, so it satisfies any min_permission threshold. This is the canonical owner rank the read-path callers (TreeBuilder, SlideViewable) inherit via #ranks_for / #readable_ids.

CreativeShare.permissions[:admin]

Instance Method Summary collapse

Constructor Details

#initialize(user:) ⇒ PermissionFilter

Returns a new instance of PermissionFilter.



18
19
20
# File 'app/services/collavre/creatives/permission_filter.rb', line 18

def initialize(user:)
  @user = user
end

Instance Method Details

#ranks_for(ids) ⇒ Object

Returns { input_id => effective_permission_rank } for the ids the user has ANY relationship to, mirroring single-item PermissionChecker: owner wins (admin rank), else the user's own cache entry (INCLUDING a no_access deny, which is rank 0 and beats a public share), else the public entry. Ids with no owner/entry/public share are ABSENT from the hash (distinct from rank 0).

The rank is the raw CreativeShare.permissions integer, so callers compare rank >= rank_for(:write) themselves. Unlike readable_ids this does NOT apply the shell-ownership anti-leak gate — it is for tree_builder / slide_viewable which rank creatives already inside the viewer's own tree, exactly as PermissionChecker#allowed? would resolve them one by one.



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'app/services/collavre/creatives/permission_filter.rb', line 77

def ranks_for(ids)
  ids = ids.to_a.filter_map { |id| Integer(id, exception: false) }.uniq
  return {} if ids.empty?

  effective_by_id = EffectiveCreativeResolution.effective_creative_ids(ids)
  effective_ids = effective_by_id.values.uniq

  owner_by_effective = Creative.where(id: effective_ids).pluck(:id, :user_id).to_h

  user_entries = if user
    CreativeSharesCache.where(creative_id: effective_ids, user_id: user.id)
      .pluck(:creative_id, :permission).to_h
  else
    {}
  end

  needs_public = effective_ids - user_entries.keys
  public_entries = if needs_public.any?
    CreativeSharesCache.where(creative_id: needs_public, user_id: nil)
      .pluck(:creative_id, :permission).to_h
  else
    {}
  end

  ranks = {}
  ids.each do |id|
    effective = effective_by_id[id]
    if user && owner_by_effective[effective] == user.id
      ranks[id] = OWNER_RANK
    else
      permission = user_entries[effective] || public_entries[effective]
      ranks[id] = CreativeShare.permissions[permission] if permission
    end
  end
  ranks
end

#readable_ids(ids, min_permission: :read) ⇒ Object

Returns the subset of ids the user may access at min_permission or higher, as an Array. min_permission: defaults to :read, so the no-arg form is unchanged; pass :write (etc.) to share the one batch filter with write-posture sites instead of re-deriving the rank comparison.

Each id is resolved to its effective (origin) creative via the SAME logic PermissionChecker uses, so a linked creative yields identical permission whether checked one-by-one or filtered in a batch.



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'app/services/collavre/creatives/permission_filter.rb', line 30

def readable_ids(ids, min_permission: :read)
  # Normalize to Integer so the final select (and owned-shell lookup) compare
  # against pluck-derived integer ids rather than string inputs; keeps the
  # canonical batch filter robust to param-sourced ids. Non-numeric ids drop.
  ids = ids.to_a.filter_map { |id| Integer(id, exception: false) }.uniq
  return [] if ids.empty?

  min_rank = rank_for(min_permission)
  effective_by_id = EffectiveCreativeResolution.effective_creative_ids(ids)
  readable_effective = readable_effective_ids(effective_by_id.values.uniq, min_rank)

  # A linked creative borrows its ORIGIN's permission, but the shell row
  # itself represents a specific PLACEMENT of that link inside a tree.
  # Returning a shell solely because its origin is readable would leak other
  # users' private shell placements into search/filter results — a batch can
  # be fed foreign shells (e.g. FilterPipeline#resolve_ancestors pulls every
  # Creative.where(origin_id: ...) regardless of owner). So a shell is gated
  # on the viewer being able to SEE its placement at the requested rank, in
  # ADDITION to origin readability: either the viewer owns the shell, or the
  # shell sits in a subtree shared with the viewer AT >= min_permission (a
  # propagated CreativeSharesCache entry on the shell row itself — e.g. a
  # public help doc's linked child). The placement is checked at min_rank
  # (not always :read) because higher-privilege callers share this batch —
  # children_with_permission(user, :admin), used by DestroyService's
  # recursive delete, must not reach a shell the viewer only has read on. A
  # shell in a foreign PRIVATE tree (no viewer entry) stays hidden. Non-shell
  # ids keep origin-readability-only behaviour.
  visible_shells = visible_shell_ids(effective_by_id, min_rank)

  ids.select do |id|
    next false unless readable_effective.include?(effective_by_id[id])

    effective_by_id[id] == id || visible_shells.include?(id)
  end
end