Class: Labimotion::ShareResolver
- Inherits:
-
Object
- Object
- Labimotion::ShareResolver
- 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
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
-
.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.
- .admin?(user) ⇒ Boolean
-
.authorize(klass, user, action) ⇒ Object
:ok — the action may proceed.
-
.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.
-
.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.
- .key_for(klass) ⇒ Object
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
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 (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 |