Class: Labimotion::ShareResolver

Inherits:
Object
  • Object
show all
Defined in:
lib/labimotion/libs/share_resolver.rb

Overview

ShareResolver The one place share rows are turned into an answer, for both shapes of question: authorize for a single record on a mutating endpoint, context_for for a whole grid — a hundred-template list must cost a fixed number of queries, not a lookup per row (template-sharing.md §7).

Defined Under Namespace

Classes: OwnerRef, Refusal

Constant Summary collapse

REQUIRED_LEVEL =

The authorization ladder (§6). Six names even where thresholds coincide: the call site documents intent, and the collapse stays visible in one constant instead of drifting apart across the codebase. Values are KlassShare::LEVELS (10 viewer / 20 editor / 30 owner) as literals — dereferencing the ActiveRecord model in this class body would force it to load in any process that touches the resolver, and the numbers are already frozen into the one-owner partial index anyway.

{
  read: 10, write: 20, release: 30, deactivate: 30, destroy: 30, manage: 30
}.freeze
UNACKNOWLEDGED_GRANTS =

The requester's inbox predicate (§7), verbatim from the design, as one SQL fragment because its three conjuncts are one idea: "a grant somebody else made me, which I have not seen in its current state".

permission_level >= 10
A level that actually confers access. A level-0 row is the caller's own standing ask,
and it belongs to the *owner's* queue, not to theirs.

created_by <> shared_with_id
Load-bearing, and it looks removable — it is not. Every owner row is a self-grant
written by KlassShare.seed_owner! on the create path (and by the seed migration for
everything older), so without this conjunct every designer would be told "you were
granted owner on…" for every template they ever made. A real grant re-stamps
created_by to the granter (KlassShareAPI's POST /shares), and transfer_ownership does
the same, so both still announce themselves.

acked_at IS NULL OR acked_at < updated_at
The whole ack mechanism — see KlassShare's comment on the column. There is no reset
logic: a later upgrade or transfer moves updated_at past acked_at and re-announces by
itself, and a revocation is a hard delete, so the entry disappears instead.

Levels as literals for the reason REQUIRED_LEVEL gives above.

'permission_level >= 10 AND created_by <> shared_with_id ' \
'AND (acked_at IS NULL OR acked_at < updated_at)'

Class Method Summary collapse

Class Method Details

.activity_for(user) ⇒ Object

The Designer's share poller (§7), over the share rows the requesting user is involved in: the rows anybody holds on the templates they own — every collaborator's, every requester's — plus the rows granting them access on other people's templates.

Three values, two questions. count and latest answer "did anything about my sharing move at all", the cheap early-out a client can use alone. digests answers "which templates", one entry per involved klass key, each the same pair taken over that klass's rows alone — so the client re-reads the rows whose digest changed, appeared or vanished instead of reloading the whole grid. That whole-grid reload is what destroyed a Designer's unsaved edits, since re-seeding the list re-seeds the open Work Area.

Opaque change tokens, not clocks. The client compares them for inequality in either direction, and none of this must ever grow "newer than" semantics: a revocation is a hard delete (KlassShare is deliberately not acts_as_paranoid), so a key's latest moves backwards while its count drops, or the key disappears from the map outright — precisely the events a timestamp cursor would silently stop detecting.

Aggregates, never the rows: this is polled about once a minute per open Designer page, and the owner of a busy template is involved in every row on it. The per-key map is a GROUP BY over the same scope, so it costs the same two round trips the bare pair did — and the pair is then derived from the groups rather than queried again, which also makes count the sum of the digests' counts by construction.

The administrator is an implicit owner everywhere but holds rows only where they really own something, so their token moves for their own templates alone. Same stance as pending_requests_map: it is the owner's signal, not a global one.



146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/labimotion/libs/share_resolver.rb', line 146

def activity_for(user)
  return { count: 0, latest: nil, digests: {} } unless shares_enabled?

  scope = involving(user).group(:klass_type, :klass_id)
  # Two round trips over one scope rather than a hand-written SELECT COUNT(*), MAX(...):
  # a write landing between them yields either a token that differs from the client's (a
  # spurious refresh) or one that matches it and is superseded by the next poll a minute
  # later. Neither loses an event, and the query stays ActiveRecord's.
  counts = scope.count
  latests = scope.maximum(:updated_at)
  { count: counts.values.sum,
    latest: latests.values.compact.max,
    digests: digests_map(counts, latests) }
end

.admin?(user) ⇒ Boolean

Returns:

  • (Boolean)


192
193
194
# File 'lib/labimotion/libs/share_resolver.rb', line 192

def admin?(user)
  user.respond_to?(:type) && user.type == 'Admin'
end

.authorize(klass, user, action) ⇒ Object

:ok — the action may proceed. :legacy — no owner row exists (or the host has not migrated): fall back to the designer-wide authenticate_admin! gate. Permanent, not transitional — an un-backfilled template degrades to "as before", never to "locked out". Refusal — the template is owned and the user's level is insufficient.

Resolution order: administrator (STI type 'Admin' — owner level on everything, implicit, no rows of its own) → the user's own share, a SegmentKlass taking the higher of its own level and its parent ElementKlass's → legacy fallback when no owner row exists. Errors propagate: an owned template must never fail open just because a query failed.

A departed owner's row still counts as "has an owner" — ignoring it would drop the klass into the legacy gate, which is exactly the fail-open the design forbids; the row waits for the super owner's transfer instead. The requesting user's own rows need no departed filter: a deleted or deactivated user cannot be logged in.



77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/labimotion/libs/share_resolver.rb', line 77

def authorize(klass, user, action)
  required = REQUIRED_LEVEL.fetch(action)
  return :legacy unless shares_enabled?
  return :ok if admin?(user)

  level = level_for(klass, user)
  return :ok if level && level >= required

  owner_row = Labimotion::KlassShare.owner_row_for(klass)
  return :legacy if owner_row.nil?

  Refusal.new(owner_row.shared_with_id)
end

.context_for(klasses, user) ⇒ Object

Per-request preload for the grids: nil when the host has not migrated (entities then keep their pre-share behaviour), else the owner and the requesting user's level per klass key, in two share queries plus one user query however long the list is. Departed users stay in the owners map on purpose — the grid badge and the awaiting-transfer list are how anyone finds out.



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/labimotion/libs/share_resolver.rb', line 96

def context_for(klasses, user)
  return nil unless shares_enabled?

  rows = Array(klasses).compact
  shares = shares_for(rows)

  {
    admin: admin?(user),
    has_owner: owned_keys(shares),
    owners: owners_map(shares),
    levels: levels_map(rows, shares, user),
    # The requesting user's legacy designer rights per klass type: what an owner-less
    # template falls back to. The ungated list endpoints serve non-designers too, so
    # capability booleans must not assume everyone reading the grid holds them.
    legacy: legacy_rights(user),
    pending_requests: pending_requests_map(shares, user)
  }
rescue StandardError => e
  # The grid must render even if share resolution breaks; entities fall back to the
  # pre-share display. Display only — `authorize` above deliberately has no such net.
  Labimotion.log_exception(e)
  nil
end

.inbox_for(user) ⇒ Object

The Designer's share inbox (§7), and the last hop of the request loop finally landing where somebody is looking: the owner's queue of standing requests, and the requester's queue of grants they have not marked seen.

Both are queries over klass_shares. There is no event table and no message log by decision, so nothing can hold ack state that disagrees with the rows themselves — a second store would immediately have its own retention question and its own idea of what is outstanding.

requests — the owner's queue: the level-0 rows on the templates the caller holds the owner row on. No column gates it and none should. A grant promotes the row off level 0 and a reject hard-deletes it, so the queue empties itself; that is the ack, and there is deliberately no dismiss-without-acting for a column to record. It is also the same predicate pending_requests_map counts for the grid's ShareBtn badge, over the same rows — so the badge and the queue cannot disagree about what is waiting.

grants — the requester's queue: see UNACKNOWLEDGED_GRANTS.

Rows rather than aggregates, unlike activity_for on the same poll tick, and bounded by what a human can act on rather than by the size of the account: a level-0 row lasts only until somebody answers it, and a grant row only until its recipient marks it seen.

At most three queries however large the account — the owned keys, then one per queue, with the requests query skipped outright for a caller who owns nothing. The caller resolves klass labels and user names on top of that, in bulk.



186
187
188
189
190
# File 'lib/labimotion/libs/share_resolver.rb', line 186

def inbox_for(user)
  return { requests: [], grants: [] } unless shares_enabled?

  { requests: pending_requests_for(user), grants: unacknowledged_grants_for(user) }
end

.key_for(klass) ⇒ Object



57
58
59
# File 'lib/labimotion/libs/share_resolver.rb', line 57

def key_for(klass)
  Labimotion::KlassShare.key_of(klass)
end