viewing_as 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ffb916a874b9d62394586bab4eb9416af1ec95d1b58a9202703b3d60aaffb462
4
+ data.tar.gz: 58a55ed4a4983bee755c07d7a24c4c534613f9dbeb41b3caaa9ba15b4d74b704
5
+ SHA512:
6
+ metadata.gz: b026ed16a77e7b0269f70a2ac530071406d61c44541a703d42e8590f11cf6d8f61fe74510393b8c180eeebb7c23074107ed592d27fe4abca35120758a8d65b9d
7
+ data.tar.gz: 4e36304c4400f95a8d3e12e2a8572d41567c19b6fde32a2e834aa86249e9d3f79c0512acdc9cafd021ffd61e349a4026b4c2a2b0e4a7b781625be52e53bb88df
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-09-22)
4
+
5
+ First release. Extracted from a production Rails 8.1 app, where it had run
6
+ since 2026-09 with the same request specs that ship here.
7
+
8
+ - Viewing sessions in a signed cookie, re-validated from the database on every request
9
+ - Read-only by default, in two layers, with a per-session override
10
+ - Fail-closed 409 when a write lands after the session has ended
11
+ - Absolute server-side timeout, logged as its own event
12
+ - Event log with owner-readable descriptions, deduped page views, append-only rows
13
+ - Consent hook checked at start and on every request
14
+ - Banner partial, `no-store`, Action Cable mixin
15
+ - `rails g viewing_as:install`
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ed
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,196 @@
1
+ # viewing_as
2
+
3
+ Let an administrator see an account as its owner sees it, without being able
4
+ to change it. Built for the Rails 8 authentication generator; works with
5
+ anything that can answer "who is signed in".
6
+
7
+ - **Read-only by default, in two layers.** Every request that is not GET or
8
+ HEAD is refused before the action runs, and the action itself runs inside
9
+ `ActiveRecord::Base.while_preventing_writes` for whatever the first layer
10
+ cannot see. Both are settings; a support team that needs to fix things as
11
+ the customer can start a writable session and get the same log.
12
+ - **Re-validated on every request.** The cookie names a target and the admin's
13
+ own session; the database says whether the admin is still an admin, whether
14
+ that session still exists, and whether the owner still permits it. A "no"
15
+ from any of those ends the session on the next click, without touching
16
+ anybody's browser.
17
+ - **Fails closed.** If the session expires or is revoked on a request that was
18
+ going to write, the response is 409 and nothing happens. Without this the
19
+ filter chain carries on with the admin as the acting user, and a click aimed
20
+ at the customer's account lands on the admin's own.
21
+ - **Time-boxed.** An absolute server-side timeout, logged when it runs out.
22
+ - **Logged in words the owner can read.** "Started viewing your account as you
23
+ see it." Refusals are logged too. Page views collapse to one row per quarter
24
+ hour; lifecycle rows never collapse; rows outlive the admin who made them
25
+ and cannot be edited through ActiveRecord.
26
+ - **Not the Rails session.** State lives in its own signed cookie. Merely
27
+ loading `session[]` makes Rack emit `Set-Cookie`, which un-caches the page at
28
+ any CDN in front of the app. Reading a second signed cookie costs nothing.
29
+
30
+ ## How it compares
31
+
32
+ | | viewing_as | pretender | devise_masquerade | switch_user |
33
+ |---|---|---|---|---|
34
+ | Read-only mode | two layers, per-session override | no | no | no |
35
+ | Re-validated from the database each request | yes | signed-in check only | no | no |
36
+ | Fails closed on mid-request expiry | 409 | n/a | n/a | n/a |
37
+ | Server-side timeout | yes, logged | no | link token only | no |
38
+ | Event log | yes, owner-readable | no | no | no |
39
+ | Consent hook | yes, checked every request | no | no | `controller_guard` |
40
+ | Storage | signed cookie | `session[]` | session or cache | `session[]` |
41
+ | Auth coupling | Rails 8 generator by default; lambdas for anything else | agnostic | Devise only | Devise, Sorcery, others |
42
+
43
+ pretender is fifty lines that swap `current_user`, and if that is all you need
44
+ it is the right choice. This library is for the case where somebody's data is
45
+ on the other side of the button.
46
+
47
+ ## Install
48
+
49
+ ```ruby
50
+ gem "viewing_as"
51
+ ```
52
+
53
+ ```
54
+ bin/rails generate viewing_as:install
55
+ bin/rails db:migrate
56
+ ```
57
+
58
+ The generator writes `config/initializers/viewing_as.rb`, a migration for
59
+ `viewing_as_events`, an `ImpersonationsController`, two routes, and then
60
+ edits three files: it includes `ViewingAs::Controller` in
61
+ `ApplicationController` after `include Authentication`, prepends
62
+ `ViewingAs::CurrentUser` into `Current`, and renders the banner at the top of
63
+ the layout. Each edit is skipped if already made.
64
+
65
+ Put a button somewhere an admin can reach:
66
+
67
+ ```erb
68
+ <%= button_to "View as #{user.email_address}", impersonation_path(user), method: :post %>
69
+ ```
70
+
71
+ The generator's `User` has no `admin` column. Add one, or tell the initializer
72
+ how else to decide:
73
+
74
+ ```ruby
75
+ ViewingAs.configure do |c|
76
+ c.may_impersonate = ->(user) { user.admin? }
77
+ end
78
+ ```
79
+
80
+ ## Consent, and every other decision
81
+
82
+ ```ruby
83
+ ViewingAs.configure do |c|
84
+ c.timeout = 30.minutes
85
+
86
+ # true, or a String saying why not. Logged as a refusal when a session
87
+ # starts; checked again on every request, so a "no" ends a running session.
88
+ c.may_be_viewed = lambda do |target, admin|
89
+ if target.admin? then "That account is an administrator."
90
+ elsif target.declined_review? then "That customer has withdrawn permission."
91
+ else true
92
+ end
93
+ end
94
+
95
+ # Read-only unless a controller says impersonate(user, read_only: false).
96
+ c.read_only = true
97
+ c.read_only_layers = %i[ policy database ]
98
+
99
+ # Keep one log: point at your own model instead of viewing_as_events. It
100
+ # must respond to record!(admin:, user:, kind:, request:, detail:); the
101
+ # kinds are the strings in ViewingAs::Kinds.
102
+ c.event_model = "AdminAccessEvent"
103
+
104
+ # Who is here, and what binds the cookie to their sign-in. The defaults read
105
+ # the generator's Current.session.
106
+ c.true_user = ->(controller) { Current.session&.user }
107
+ c.session_id = ->(controller) { Current.session&.id }
108
+ c.find_target = ->(id) { User.find_by(id: id) }
109
+ c.display = ->(user) { user.email_address }
110
+ end
111
+ ```
112
+
113
+ ## In a controller
114
+
115
+ ```ruby
116
+ attempt = impersonate(user) # or impersonate(user, read_only: false)
117
+ attempt.started? # true, or false with attempt.reason
118
+ end_impersonation! # the way out; logs a stop row
119
+
120
+ true_user # who is actually here; use for every permission check
121
+ impersonated_user # who the request acts as, or nil
122
+ impersonating?
123
+ impersonation_read_only?
124
+ impersonation_expires_at
125
+ ```
126
+
127
+ `Current.user` answers with the viewed account while a session is on, which
128
+ is what lets the rest of the app work unmodified. Anything that must know who
129
+ is really here reads `true_user`.
130
+
131
+ The stop action is the one write the read-only guards must let through. The
132
+ generated controller skips them on `destroy` and nowhere else:
133
+
134
+ ```ruby
135
+ skip_before_action :refuse_writes_while_impersonating, only: :destroy
136
+ skip_around_action :read_only_while_impersonating, only: :destroy
137
+ ```
138
+
139
+ An admin area should refuse to render while a session is on, or every
140
+ `Current.user` on every page would be the customer, including the ones
141
+ deciding what to render:
142
+
143
+ ```ruby
144
+ before_action { redirect_to root_path, alert: "Stop viewing first." if impersonating? }
145
+ ```
146
+
147
+ ## Action Cable
148
+
149
+ A connection identifies itself by the session's user and has nothing the
150
+ read-only guard can wrap. Refuse it while viewing:
151
+
152
+ ```ruby
153
+ class ApplicationCable::Connection < ActionCable::Connection::Base
154
+ include ViewingAs::Cable
155
+
156
+ def connect
157
+ reject_unauthorized_connection if viewing_another_account?
158
+ ...
159
+ ```
160
+
161
+ ## What the owner sees
162
+
163
+ `ViewingAs::Event` rows for a user, newest first, each with `description`,
164
+ `reviewer`, `detail` and `created_at`:
165
+
166
+ ```ruby
167
+ ViewingAs::Event.where(user: current_user).newest_first
168
+ ```
169
+
170
+ These rows live in the same database as everything else. They bound what the
171
+ application permits, not what a person with a console can do. Say so wherever
172
+ you describe the log to your users.
173
+
174
+ ## What it does not cover
175
+
176
+ - Writes that are not ActiveRecord: a viewed GET that enqueues a job, sends
177
+ mail or writes to S3 still does. A spec asserting no job is enqueued during
178
+ a viewed GET is what keeps that true in the app this was extracted from.
179
+ - A CDN that forwards cookies on some paths and not others will render the
180
+ banner only where the cookie reaches. That is also where the owner's data
181
+ can reach, so nothing is disclosed without the banner; what is lost is the
182
+ reminder while wandering the public pages.
183
+
184
+ ## Development
185
+
186
+ ```
187
+ bundle install
188
+ bundle exec rspec
189
+ ```
190
+
191
+ The specs run against `spec/dummy`, a Rails app with the authentication
192
+ generator's output and this library's install applied.
193
+
194
+ ## License
195
+
196
+ MIT.
@@ -0,0 +1,74 @@
1
+ module ViewingAs
2
+ # One record of someone on the team looking at an account through a viewing
3
+ # session. The account owner reads their own rows, which is what makes this
4
+ # a control rather than a promise.
5
+ #
6
+ # These rows live in the same database as everything else, so they bound
7
+ # what the application permits, not what a person with a console can do.
8
+ # Say that out loud wherever you describe the log to your users.
9
+ class Event < ActiveRecord::Base
10
+ self.table_name = "viewing_as_events"
11
+
12
+ # Optional, because the row has to outlive the account it names. #reviewer
13
+ # is what views should read; the association is a convenience on top of
14
+ # the address, not the source of it.
15
+ belongs_to :admin_user, class_name: ViewingAs.config.user_class, optional: true
16
+ belongs_to :user, class_name: ViewingAs.config.user_class
17
+
18
+ validates :kind, inclusion: { in: Kinds::ALL }
19
+
20
+ scope :newest_first, -> { order(created_at: :desc, id: :desc) }
21
+
22
+ # A log an admin can edit is not a log. Binds ActiveRecord only.
23
+ def readonly?
24
+ persisted?
25
+ end
26
+
27
+ def description
28
+ Kinds::DESCRIPTIONS.fetch(kind) { kind.humanize }
29
+ end
30
+
31
+ # Who looked, as recorded at the time. Survives the account being deleted,
32
+ # which is the whole reason the column exists.
33
+ def reviewer
34
+ admin_email.presence ||
35
+ (admin_user && ViewingAs.config.display.call(admin_user)) ||
36
+ "a former team member"
37
+ end
38
+
39
+ # Writes one row, or doesn't, and never raises in the middle of a page.
40
+ #
41
+ # Returns the row, or nil when it was deduped away. Callers use it for its
42
+ # effect; nothing branches on the return.
43
+ def self.record!(admin:, user:, kind:, request: nil, detail: nil)
44
+ return nil if admin.nil? || user.nil?
45
+
46
+ kind = kind.to_s
47
+ return nil if deduped?(admin, user, kind, detail)
48
+
49
+ create!(admin_user: admin, admin_email: ViewingAs.config.display.call(admin),
50
+ user: user, kind: kind, detail: detail,
51
+ ip_address: request&.remote_ip,
52
+ user_agent: request&.user_agent&.to_s&.truncate(255))
53
+ rescue ActiveRecord::ActiveRecordError => e
54
+ # An audit row failing must not take down the page it was auditing. Loud
55
+ # in the log, silent on the screen.
56
+ logger.error("[ViewingAs::Event] could not record #{kind}: #{e.class}: #{e.message}")
57
+ nil
58
+ end
59
+
60
+ # One row per admin, per account, per kind, per detail, per window. Keyed
61
+ # on the detail too, or ten distinct reads of one kind inside a window
62
+ # would write one row naming the first and say nothing about the rest.
63
+ #
64
+ # No unique index behind this: two concurrent requests can both pass and
65
+ # write a pair of rows, which in an append-only log is harmless.
66
+ def self.deduped?(admin, user, kind, detail)
67
+ return false if Kinds::DISCRETE.include?(kind)
68
+
69
+ where(admin_user_id: admin.id, user_id: user.id, kind: kind, detail: detail,
70
+ created_at: ViewingAs.config.dedupe_window.ago..).exists?
71
+ end
72
+ private_class_method :deduped?
73
+ end
74
+ end
@@ -0,0 +1,21 @@
1
+ <%# Self-gating: render it unconditionally from the layout. Unstyled on purpose;
2
+ copy it into app/views/viewing_as/_banner.html.erb to change the words or
3
+ add classes. Pass stop_path: if your stop route is named differently. %>
4
+ <% if impersonating? %>
5
+ <div class="viewing-as-banner" role="status">
6
+ <span class="viewing-as-banner__text">
7
+ You are viewing <strong><%= ViewingAs.config.display.call(impersonated_user) %></strong>
8
+ as <%= ViewingAs.config.display.call(true_user) %>.
9
+ <% if impersonation_read_only? %>
10
+ Nothing you do here can change their account, and they can see a record of this visit.
11
+ <% else %>
12
+ Everything you do here is done as them, and they can see a record of this visit.
13
+ <% end %>
14
+ <% if impersonation_expires_at %>
15
+ Ends in <%= distance_of_time_in_words(Time.current, impersonation_expires_at) %>.
16
+ <% end %>
17
+ </span>
18
+ <%= button_to "Stop viewing", local_assigns.fetch(:stop_path) { stop_impersonation_path },
19
+ method: :delete, class: "viewing-as-banner__stop" %>
20
+ </div>
21
+ <% end %>
@@ -0,0 +1,101 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module ViewingAs
5
+ module Generators
6
+ # rails generate viewing_as:install
7
+ #
8
+ # Writes the initializer, the events migration and an ImpersonationsController;
9
+ # adds the two routes; includes the concern in ApplicationController after
10
+ # Authentication; prepends the Current mixin; renders the banner in the
11
+ # layout. Every step is idempotent and says what it skipped.
12
+ class InstallGenerator < Rails::Generators::Base
13
+ include ActiveRecord::Generators::Migration
14
+
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ class_option :skip_migration, type: :boolean, default: false,
18
+ desc: "Skip the viewing_as_events migration (you keep your own log)"
19
+
20
+ def create_initializer
21
+ template "initializer.rb", "config/initializers/viewing_as.rb"
22
+ end
23
+
24
+ def create_migration_file
25
+ return if options[:skip_migration]
26
+
27
+ migration_template "migration.rb.tt", "db/migrate/create_viewing_as_events.rb"
28
+ end
29
+
30
+ def create_controller
31
+ template "impersonations_controller.rb", "app/controllers/impersonations_controller.rb"
32
+ end
33
+
34
+ def add_routes
35
+ route <<~ROUTES
36
+ # Viewing an account as its owner. No id on the stop route: the cookie
37
+ # already knows who, which is what lets the banner offer it from any page.
38
+ post "impersonations/:id", to: "impersonations#create", as: :impersonation
39
+ delete "impersonation", to: "impersonations#destroy", as: :stop_impersonation
40
+ ROUTES
41
+ end
42
+
43
+ def include_controller_concern
44
+ path = "app/controllers/application_controller.rb"
45
+ line = " include ViewingAs::Controller\n"
46
+ return say_status(:skip, "#{path} already includes ViewingAs::Controller", :yellow) if contains?(path, "ViewingAs::Controller")
47
+
48
+ if contains?(path, /^\s*include Authentication\s*$/)
49
+ inject_into_file path, line, after: /^\s*include Authentication\s*\n/
50
+ else
51
+ inject_into_class path, "ApplicationController", line
52
+ end
53
+ end
54
+
55
+ def prepend_current_mixin
56
+ path = "app/models/current.rb"
57
+ if File.exist?(destination(path))
58
+ return say_status(:skip, "#{path} already prepends ViewingAs::CurrentUser", :yellow) if contains?(path, "ViewingAs::CurrentUser")
59
+
60
+ inject_into_class path, "Current", " prepend ViewingAs::CurrentUser\n"
61
+ else
62
+ template "current.rb", path
63
+ end
64
+ end
65
+
66
+ def render_banner
67
+ path = "app/views/layouts/application.html.erb"
68
+ return say_status(:skip, "#{path} not found; render \"viewing_as/banner\" in your layout", :yellow) unless File.exist?(destination(path))
69
+ return say_status(:skip, "#{path} already renders the banner", :yellow) if contains?(path, "viewing_as/banner")
70
+
71
+ inject_into_file path, " <%= render \"viewing_as/banner\" %>\n", after: /<body[^>]*>\n/
72
+ end
73
+
74
+ def show_next_steps
75
+ say ""
76
+ say "Next:"
77
+ say " 1. Review config/initializers/viewing_as.rb: may_impersonate and may_be_viewed are the policy."
78
+ say " 2. bin/rails db:migrate" unless options[:skip_migration]
79
+ say " 3. Put a button somewhere: button_to \"View as\", impersonation_path(user), method: :post"
80
+ say " 4. Style .viewing-as-banner, or copy the partial to app/views/viewing_as/_banner.html.erb."
81
+ end
82
+
83
+ private
84
+
85
+ def users_table
86
+ ViewingAs.config.user_klass.table_name
87
+ rescue StandardError
88
+ "users"
89
+ end
90
+
91
+ def destination(path)
92
+ File.join(destination_root, path)
93
+ end
94
+
95
+ def contains?(path, needle)
96
+ full = destination(path)
97
+ File.exist?(full) && File.read(full).match?(needle.is_a?(Regexp) ? needle : Regexp.new(Regexp.escape(needle)))
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,6 @@
1
+ class Current < ActiveSupport::CurrentAttributes
2
+ prepend ViewingAs::CurrentUser
3
+
4
+ attribute :session
5
+ delegate :user, to: :session, allow_nil: true
6
+ end
@@ -0,0 +1,38 @@
1
+ # Starting and stopping a viewing session. The read-only guards are inherited
2
+ # from ApplicationController; #destroy is the one write they must let through,
3
+ # because it is the way out.
4
+ class ImpersonationsController < ApplicationController
5
+ # NOT require_authentication, deliberately. That redirects to sign-in, and a
6
+ # redirect is an answer: it tells whoever is poking at the URL that it
7
+ # exists and wants a session. A signed-out stranger, a signed-in customer
8
+ # and a made-up id all get the same 404 below.
9
+ allow_unauthenticated_access
10
+ before_action :require_impersonator
11
+ skip_before_action :refuse_writes_while_impersonating, only: :destroy
12
+ skip_around_action :read_only_while_impersonating, only: :destroy
13
+
14
+ def create
15
+ target = User.find(params[:id])
16
+ attempt = impersonate(target)
17
+
18
+ if attempt.started?
19
+ redirect_to root_path, notice: "Viewing as #{ViewingAs.config.display.call(target)}."
20
+ else
21
+ redirect_back_or_to root_path, alert: attempt.reason
22
+ end
23
+ end
24
+
25
+ def destroy
26
+ end_impersonation!
27
+ redirect_to root_path, notice: "Stopped viewing that account.", status: :see_other
28
+ end
29
+
30
+ private
31
+
32
+ def require_impersonator
33
+ ViewingAs.config.resume_session.call(self)
34
+ return if true_user && ViewingAs.config.may_impersonate.call(true_user)
35
+
36
+ raise ActiveRecord::RecordNotFound
37
+ end
38
+ end
@@ -0,0 +1,51 @@
1
+ # Viewing an account as its owner. The lambdas below are the policy; the
2
+ # library is the mechanism. Every default here matches the Rails 8
3
+ # authentication generator; change what does not match your app.
4
+ ViewingAs.configure do |c|
5
+ # How long a viewing session lasts. Absolute, enforced server-side, logged
6
+ # when it runs out.
7
+ c.timeout = 30.minutes
8
+
9
+ # Who may view other accounts. The default asks the signed-in user `admin?`;
10
+ # the generator's User has no such column, so add one or answer some other way.
11
+ # c.may_impersonate = ->(user) { user.admin? }
12
+
13
+ # Whether a given account may be viewed, by whom. Return true, or a String
14
+ # saying why not; the String is logged as a refusal and shown to the admin.
15
+ # Checked again on every request, so a "no" ends a running session at once.
16
+ # This is where the account owner's consent belongs.
17
+ # c.may_be_viewed = lambda do |target, admin|
18
+ # if target.admin? then "That account is an administrator."
19
+ # elsif target.declined_review? then "That customer has withdrawn permission."
20
+ # else true
21
+ # end
22
+ # end
23
+
24
+ # Read-only by default: no non-GET requests, and no ActiveRecord writes,
25
+ # while viewing. A controller may start a writable session with
26
+ # impersonate(user, read_only: false); the log records which kind it was.
27
+ c.read_only = true
28
+
29
+ # Which read-only guards run: :policy (refuse non-GET) and :database
30
+ # (ActiveRecord::Base.while_preventing_writes). Drop :database only if a
31
+ # viewed GET genuinely has to write, and you accept what that means.
32
+ c.read_only_layers = %i[ policy database ]
33
+
34
+ # Where events go. The default table is viewing_as_events (see the migration).
35
+ # Point this at your own model if you already keep a log; it must respond to
36
+ # record!(admin:, user:, kind:, request:, detail:).
37
+ # c.event_model = "AdminAccessEvent"
38
+
39
+ # Page-view rows within this window collapse into one. Start, stop, expiry,
40
+ # revocation and refusal rows never do.
41
+ c.dedupe_window = 15.minutes
42
+
43
+ # How to name a person in the banner and the log.
44
+ # c.display = ->(user) { user.email_address }
45
+
46
+ # Advanced: who is signed in, and what binds the cookie to their sign-in.
47
+ # The defaults read the generator's Current.session.
48
+ # c.true_user = ->(controller) { Current.session&.user }
49
+ # c.session_id = ->(controller) { Current.session&.id }
50
+ # c.find_target = ->(id) { User.find_by(id: id) }
51
+ end
@@ -0,0 +1,20 @@
1
+ class CreateViewingAsEvents < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :viewing_as_events do |t|
4
+ # Nullified, not cascaded, when the admin's account goes: the row has to
5
+ # outlive the person it names. admin_email is what views should show.
6
+ t.references :admin_user, foreign_key: { to_table: :<%= users_table %>, on_delete: :nullify }
7
+ t.string :admin_email
8
+ t.references :user, null: false, foreign_key: { to_table: :<%= users_table %>, on_delete: :cascade }
9
+ t.string :kind, null: false
10
+ t.string :detail
11
+ t.string :ip_address
12
+ t.string :user_agent
13
+ t.datetime :created_at, null: false
14
+ end
15
+
16
+ add_index :viewing_as_events, [ :admin_user_id, :user_id, :kind, :created_at ],
17
+ name: "index_viewing_as_events_on_dedupe"
18
+ add_index :viewing_as_events, [ :user_id, :created_at ]
19
+ end
20
+ end
@@ -0,0 +1,24 @@
1
+ module ViewingAs
2
+ # For ApplicationCable::Connection.
3
+ #
4
+ # A connection identifies itself by the SESSION's user and has no request,
5
+ # no filter chain and nothing the read-only guard can wrap. A channel opened
6
+ # while an admin is viewing an account would therefore run as the admin,
7
+ # with the admin's reach, from a page rendered as somebody else. The honest
8
+ # answer is to refuse the connection until the viewing session ends:
9
+ #
10
+ # class Connection < ActionCable::Connection::Base
11
+ # include ViewingAs::Cable
12
+ # identified_by :current_user
13
+ #
14
+ # def connect
15
+ # reject_unauthorized_connection if viewing_another_account?
16
+ # ...
17
+ module Cable
18
+ private
19
+
20
+ def viewing_another_account?
21
+ cookies.signed[ViewingAs.config.cookie_name].present?
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,131 @@
1
+ module ViewingAs
2
+ # Every decision that belongs to the host application, made explicit.
3
+ #
4
+ # The defaults are the Rails 8 authentication generator's shapes: a Current
5
+ # with a `session` whose `user` is who is signed in, a `User` model, and an
6
+ # `admin?` predicate on it. A host built some other way overrides the lambdas
7
+ # and nothing else in the library needs to know.
8
+ class Configuration
9
+ # How long a viewing session lasts from the moment it starts. Absolute, not
10
+ # sliding: enforced server-side off the timestamp in the cookie, and a
11
+ # session that runs out is logged as expired.
12
+ attr_accessor :timeout
13
+
14
+ # Name of the signed cookie that carries the viewing state. It is NOT the
15
+ # Rails session, and that is deliberate: reading a second signed cookie
16
+ # costs nothing on a page that never loads the session, whereas merely
17
+ # loading `session[]` makes Rack emit Set-Cookie and un-caches the page at
18
+ # any CDN in front of the app.
19
+ attr_accessor :cookie_name
20
+
21
+ # ->(controller) { who is actually here }. Every permission check and every
22
+ # log row uses this, never the impersonated user.
23
+ attr_accessor :true_user
24
+
25
+ # ->(controller) { an id that changes when the true user signs out }. The
26
+ # cookie is bound to it, so a cookie minted under one sign-in is inert
27
+ # under the next.
28
+ attr_accessor :session_id
29
+
30
+ # ->(controller) { load the sign-in state }. Called before the true user is
31
+ # consulted on every request that carries the cookie, so that surfaces
32
+ # which skip authentication still see the banner.
33
+ attr_accessor :resume_session
34
+
35
+ # ->(id) { the account named in the cookie, or nil }.
36
+ attr_accessor :find_target
37
+
38
+ # ->(user) { true if this person may view accounts at all }.
39
+ attr_accessor :may_impersonate
40
+
41
+ # ->(target, admin) { true, or a String saying why not }. Consulted when a
42
+ # session starts (the reason is logged as a refusal) and again on every
43
+ # request while it lasts (a false answer ends it, logged as revoked). This
44
+ # is where consent lives: return a reason when the account owner has said no.
45
+ attr_accessor :may_be_viewed
46
+
47
+ # ->(user) { how to name this person in the banner and the log }.
48
+ attr_accessor :display
49
+
50
+ # The class (or its name) that records events. Must respond to
51
+ # record!(admin:, user:, kind:, request:, detail:). The default is the
52
+ # library's own table; point it at your own model to keep one log.
53
+ attr_accessor :event_model
54
+
55
+ # Name of the host's user class, for the default event model's associations.
56
+ attr_accessor :user_class
57
+
58
+ # For the default event model: page-view rows within this window collapse
59
+ # into one. Lifecycle rows (start, stop, expired, revoked, refused) never do.
60
+ attr_accessor :dedupe_window
61
+
62
+ # Whether a viewing session is read-only unless the controller says
63
+ # otherwise when it starts one. See #read_only_layers.
64
+ attr_accessor :read_only
65
+
66
+ # Which guards enforce read-only. :policy refuses every request that is not
67
+ # GET or HEAD. :database wraps the action in
68
+ # ActiveRecord::Base.while_preventing_writes for whatever the policy cannot
69
+ # see. Both by default; drop :database only if an impersonated GET has to
70
+ # write something and you accept what that means.
71
+ attr_accessor :read_only_layers
72
+
73
+ # Stamp Cache-Control: no-store on every response rendered while viewing,
74
+ # so no cache between the browser and the app can hand one person's page
75
+ # to the next.
76
+ attr_accessor :no_store
77
+
78
+ # -> { whether the cookie is marked Secure }.
79
+ attr_accessor :secure_cookie
80
+
81
+ def initialize
82
+ @timeout = 30.minutes
83
+ @cookie_name = :impersonation
84
+ @true_user = ->(_controller) { ::Current.session&.user }
85
+ @session_id = ->(_controller) { ::Current.session&.id }
86
+ @resume_session = lambda do |controller|
87
+ controller.send(:resume_session) if controller.respond_to?(:resume_session, true)
88
+ end
89
+ @find_target = ->(id) { ViewingAs.config.user_klass.find_by(id: id) }
90
+ @may_impersonate = ->(user) { user.respond_to?(:admin?) && user.admin? }
91
+ @may_be_viewed = lambda do |target, _admin|
92
+ if target.respond_to?(:admin?) && target.admin?
93
+ "That account is an administrator."
94
+ else
95
+ true
96
+ end
97
+ end
98
+ @display = ->(user) { user.try(:email_address) || user.try(:email) || user.to_s }
99
+ @event_model = "ViewingAs::Event"
100
+ @user_class = "User"
101
+ @dedupe_window = 15.minutes
102
+ @read_only = true
103
+ @read_only_layers = %i[ policy database ]
104
+ @no_store = true
105
+ @secure_cookie = -> { defined?(::Rails) && ::Rails.env.production? }
106
+ end
107
+
108
+ def event_class
109
+ event_model.is_a?(String) ? event_model.constantize : event_model
110
+ end
111
+
112
+ def user_klass
113
+ user_class.is_a?(String) ? user_class.constantize : user_class
114
+ end
115
+
116
+ def read_only_layer?(layer)
117
+ Array(read_only_layers).map(&:to_sym).include?(layer)
118
+ end
119
+
120
+ # may_be_viewed may take (target) or (target, admin). Returns nil when the
121
+ # account may be viewed, otherwise the reason it may not.
122
+ def refusal_for(target, admin)
123
+ answer = may_be_viewed.arity.abs >= 2 ? may_be_viewed.call(target, admin) : may_be_viewed.call(target)
124
+ case answer
125
+ when true, nil then nil
126
+ when String then answer
127
+ else "That account cannot be viewed."
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,248 @@
1
+ require "json"
2
+
3
+ module ViewingAs
4
+ # The controller side. Include it AFTER the callback that loads the sign-in
5
+ # state, because the guards below assume it has run:
6
+ #
7
+ # class ApplicationController < ActionController::Base
8
+ # include Authentication
9
+ # include ViewingAs::Controller
10
+ # end
11
+ #
12
+ # Registration order, top to bottom, is load-bearing:
13
+ #
14
+ # resume_impersonation ..... re-derives the viewing state from the cookie
15
+ # and the database, on every request, before anything renders. Also
16
+ # where the page-view row is logged: deliberately before the
17
+ # around_action that forbids writes.
18
+ # refuse_writes ............ the policy: no non-GET while viewing read-only.
19
+ # read_only ................ the guarantee, for whatever the policy cannot see.
20
+ # no_store ................. so no cache between here and the browser keeps
21
+ # a page rendered for one named person.
22
+ module Controller
23
+ extend ActiveSupport::Concern
24
+
25
+ # What #impersonate returns.
26
+ Attempt = Data.define(:started, :reason) do
27
+ def started? = started
28
+ end
29
+
30
+ included do
31
+ before_action :resume_impersonation
32
+ before_action :refuse_writes_while_impersonating
33
+ around_action :read_only_while_impersonating
34
+ after_action :no_store_while_impersonating
35
+
36
+ if respond_to?(:helper_method)
37
+ helper_method :true_user, :impersonated_user, :impersonating?,
38
+ :impersonation_expires_at, :impersonation_read_only?
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ # Who is actually here. Every permission check and every log row uses this;
45
+ # using the acting user for either would let a viewed request authorise
46
+ # itself as the account owner, or file the admin's reads under the owner's
47
+ # own name.
48
+ def true_user
49
+ ViewingAs.config.true_user.call(self)
50
+ end
51
+
52
+ # Who this request acts as, when someone is viewing; nil otherwise.
53
+ def impersonated_user
54
+ ViewingAs::Current.impersonated_user
55
+ end
56
+
57
+ def impersonating?
58
+ ViewingAs::Current.impersonating?
59
+ end
60
+
61
+ def impersonation_expires_at
62
+ ViewingAs::Current.expires_at
63
+ end
64
+
65
+ def impersonation_read_only?
66
+ impersonating? && ViewingAs::Current.read_only == true
67
+ end
68
+
69
+ # Starts viewing `target`, or refuses and says why. Both outcomes are
70
+ # logged. Returns an Attempt: check #started? and read #reason.
71
+ #
72
+ # read_only: nil takes the configured default. Pass false to start a
73
+ # session that may write; the choice is sealed into the signed cookie, so
74
+ # it cannot be changed from the browser once made, and recorded on the
75
+ # start row so the log says which kind of session it was.
76
+ def impersonate(target, read_only: nil)
77
+ admin = true_user
78
+
79
+ unless admin && ViewingAs.config.may_impersonate.call(admin)
80
+ return refuse_impersonation(admin, target, "You are not permitted to view other accounts.")
81
+ end
82
+ return refuse_impersonation(admin, target, "You cannot view your own account this way.") if target == admin
83
+ if (reason = ViewingAs.config.refusal_for(target, admin))
84
+ return refuse_impersonation(admin, target, reason)
85
+ end
86
+
87
+ begin_impersonation!(target, read_only: read_only)
88
+ record_viewing_event(admin, target, Kinds::START,
89
+ detail: (impersonation_read_only? ? nil : "writable"))
90
+ Attempt.new(started: true, reason: nil)
91
+ end
92
+
93
+ # Ends the current viewing session, logging `kind`. Safe to call when
94
+ # nothing is being viewed.
95
+ def end_impersonation!(kind = Kinds::STOP, subject = nil)
96
+ subject ||= ViewingAs.config.find_target.call(impersonation_payload&.dig("user_id"))
97
+ cookies.delete(ViewingAs.config.cookie_name)
98
+ ViewingAs::Current.impersonated_user = nil
99
+ ViewingAs::Current.expires_at = nil
100
+ ViewingAs::Current.read_only = nil
101
+
102
+ record_viewing_event(true_user, subject, kind)
103
+ nil
104
+ end
105
+
106
+ # Mints the cookie and sets the state, with no checks and no log row.
107
+ # #impersonate is the front door; this is here for hosts that have already
108
+ # decided.
109
+ def begin_impersonation!(target, read_only: nil)
110
+ read_only = ViewingAs.config.read_only if read_only.nil?
111
+ started_at = Time.current
112
+
113
+ cookies.signed[ViewingAs.config.cookie_name] = {
114
+ value: { admin_session_id: ViewingAs.config.session_id.call(self),
115
+ user_id: target.id,
116
+ started_at: started_at.to_i,
117
+ read_only: read_only }.to_json,
118
+ httponly: true,
119
+ same_site: :lax,
120
+ secure: ViewingAs.config.secure_cookie.call
121
+ # Deliberately NO expires. The timeout is enforced server-side off
122
+ # started_at, and a cookie carrying the same lifetime would simply
123
+ # vanish from the browser first, so the expiry would never be seen here
124
+ # and never written to the log, leaving the account owner a session
125
+ # that started and apparently never ended.
126
+ }
127
+
128
+ ViewingAs::Current.impersonated_user = target
129
+ ViewingAs::Current.expires_at = started_at + ViewingAs.config.timeout
130
+ ViewingAs::Current.read_only = read_only
131
+ end
132
+
133
+ # Re-derived from scratch on every request rather than trusted from the
134
+ # cookie we minted. That is the whole design: consent withdrawn, an admin
135
+ # flag revoked, an account closed and a sign-out elsewhere all take effect
136
+ # on the very next click, without reaching into anybody's browser.
137
+ def resume_impersonation
138
+ payload = impersonation_payload
139
+ return if payload.nil?
140
+
141
+ ViewingAs.config.resume_session.call(self)
142
+ admin = true_user
143
+ target = ViewingAs.config.find_target.call(payload["user_id"])
144
+ started_at = payload["started_at"].to_i
145
+
146
+ if started_at < ViewingAs.config.timeout.ago.to_i
147
+ return finish_impersonation(Kinds::EXPIRED, target)
148
+ end
149
+
150
+ unless admin && ViewingAs.config.may_impersonate.call(admin) &&
151
+ ViewingAs.config.session_id.call(self) == payload["admin_session_id"]
152
+ return finish_impersonation(Kinds::REVOKED, target)
153
+ end
154
+
155
+ unless target && target != admin && ViewingAs.config.refusal_for(target, admin).nil?
156
+ return finish_impersonation(Kinds::REVOKED, target)
157
+ end
158
+
159
+ ViewingAs::Current.impersonated_user = target
160
+ ViewingAs::Current.expires_at = at(started_at) + ViewingAs.config.timeout
161
+ ViewingAs::Current.read_only = payload.key?("read_only") ? payload["read_only"] : ViewingAs.config.read_only
162
+
163
+ record_viewing_event(admin, target, Kinds::READ)
164
+ end
165
+
166
+ # Ends a session that has stopped being valid, and refuses the request if
167
+ # it was going to write.
168
+ #
169
+ # The refusal is the whole point. Without it the filter chain simply
170
+ # carries on: nothing is being viewed any more, so the write guard waves
171
+ # the request through, and the acting user has silently become the ADMIN.
172
+ # A click aimed at somebody else's account would land on the admin's own.
173
+ # The only honest answer is not to do it to theirs. This holds in writable
174
+ # sessions too; it is about whose account the click lands on, not about
175
+ # whether writes were allowed.
176
+ #
177
+ # Nobody signed in means nobody's account to land on: a sign-in POST made
178
+ # while holding a dead cookie is the visitor's own, and goes through.
179
+ def finish_impersonation(kind, subject)
180
+ end_impersonation!(kind, subject)
181
+ return if request.env["REQUEST_METHOD"].in?(%w[ GET HEAD ])
182
+ return if true_user.nil?
183
+
184
+ render plain: "That session ended before this went through. Nothing was changed.",
185
+ status: :conflict
186
+ end
187
+
188
+ # The policy: reads only.
189
+ #
190
+ # Read straight off the Rack env, the same method the router dispatched
191
+ # on. Rack::MethodOverride resolves _method and X-Http-Method-Override
192
+ # before either runs, so there is no spelling of a request that routes to
193
+ # a write action while presenting here as a GET.
194
+ def refuse_writes_while_impersonating
195
+ return unless impersonation_read_only? && ViewingAs.config.read_only_layer?(:policy)
196
+ return if request.env["REQUEST_METHOD"].in?(%w[ GET HEAD ])
197
+
198
+ render plain: "Read-only while viewing another account.", status: :forbidden
199
+ end
200
+
201
+ # The guarantee, for everything the policy above cannot see: an
202
+ # update_column in a helper, a counter cache, a touch-on-read added later.
203
+ # Reaching this is a bug rather than a user action, which is why it logs
204
+ # at error.
205
+ #
206
+ # Covers ActiveRecord only. A GET that enqueued a job, sent mail or wrote
207
+ # to S3 would still fire; a spec asserting no job is enqueued during a
208
+ # viewed GET is what keeps that true.
209
+ def read_only_while_impersonating
210
+ return yield unless impersonation_read_only? && ViewingAs.config.read_only_layer?(:database)
211
+
212
+ ActiveRecord::Base.while_preventing_writes { yield }
213
+ rescue ActiveRecord::ReadOnlyError => e
214
+ logger.error("[ViewingAs] blocked a write: #{e.message}") if respond_to?(:logger) && logger
215
+ render plain: "Read-only while viewing another account.", status: :forbidden
216
+ end
217
+
218
+ # "No Cache-Control" is not "no caching": a CDN answers it with its default
219
+ # TTL and would happily cache one person's account page for the next
220
+ # visitor. Say it.
221
+ def no_store_while_impersonating
222
+ response.headers["Cache-Control"] = "no-store" if impersonating? && ViewingAs.config.no_store
223
+ end
224
+
225
+ def impersonation_payload
226
+ raw = cookies.signed[ViewingAs.config.cookie_name]
227
+ raw.blank? ? nil : JSON.parse(raw)
228
+ rescue JSON::ParserError
229
+ # A signed cookie that survived verification but is not the JSON we
230
+ # wrote: ours, from an older shape. Treat as absent rather than 500ing.
231
+ nil
232
+ end
233
+
234
+ def refuse_impersonation(admin, target, reason)
235
+ record_viewing_event(admin, target, Kinds::REFUSED, detail: reason)
236
+ Attempt.new(started: false, reason: reason)
237
+ end
238
+
239
+ def record_viewing_event(admin, user, kind, detail: nil)
240
+ ViewingAs.config.event_class.record!(admin: admin, user: user, kind: kind,
241
+ request: request, detail: detail)
242
+ end
243
+
244
+ def at(epoch)
245
+ Time.zone ? Time.zone.at(epoch) : Time.at(epoch)
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,16 @@
1
+ module ViewingAs
2
+ # The viewing state for the current request. Reset with every other
3
+ # CurrentAttributes at the end of the request.
4
+ #
5
+ # Kept separate from the host's Current so the library never has to know
6
+ # what that class is called or what else it holds. ViewingAs::CurrentUser is
7
+ # the bridge that makes the host's Current.user answer with the viewed
8
+ # account.
9
+ class Current < ActiveSupport::CurrentAttributes
10
+ attribute :impersonated_user, :expires_at, :read_only
11
+
12
+ def impersonating?
13
+ impersonated_user.present?
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,49 @@
1
+ module ViewingAs
2
+ # Prepend into the host's Current so that Current.user is the viewed account
3
+ # while a session is on, and the signed-in user otherwise:
4
+ #
5
+ # class Current < ActiveSupport::CurrentAttributes
6
+ # prepend ViewingAs::CurrentUser
7
+ # attribute :session
8
+ # delegate :user, to: :session, allow_nil: true
9
+ # end
10
+ #
11
+ # Prepend, not include, so it wins over a `delegate :user` or a hand-written
12
+ # `user` already on the class; `super` then reaches whichever the host had.
13
+ # With no such method it falls back to `session&.user`, the generator's shape.
14
+ #
15
+ # This is what lets every controller, model and job that reads Current.user
16
+ # work unmodified while an admin is viewing an account. Anything that must
17
+ # know who is really here reads Current.session.user, or the controller's
18
+ # true_user.
19
+ module CurrentUser
20
+ def self.prepended(base)
21
+ base.singleton_class.delegate :impersonated_user, :impersonation_expires_at,
22
+ :impersonation_read_only, :impersonating?, to: :instance
23
+ end
24
+
25
+ def self.included(base)
26
+ prepended(base)
27
+ end
28
+
29
+ def user
30
+ ViewingAs::Current.impersonated_user || (defined?(super) ? super : session&.user)
31
+ end
32
+
33
+ def impersonated_user
34
+ ViewingAs::Current.impersonated_user
35
+ end
36
+
37
+ def impersonation_expires_at
38
+ ViewingAs::Current.expires_at
39
+ end
40
+
41
+ def impersonation_read_only
42
+ ViewingAs::Current.read_only
43
+ end
44
+
45
+ def impersonating?
46
+ ViewingAs::Current.impersonating?
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,8 @@
1
+ module ViewingAs
2
+ # Autoloads app/models/viewing_as/event.rb and serves the banner partial from
3
+ # app/views/viewing_as/. No routes: the host owns the two it needs, which the
4
+ # install generator writes.
5
+ class Engine < ::Rails::Engine
6
+ engine_name "viewing_as"
7
+ end
8
+ end
@@ -0,0 +1,33 @@
1
+ module ViewingAs
2
+ # The event kinds, as strings, so a host that keeps its own log can store the
3
+ # same values without loading the library's model.
4
+ module Kinds
5
+ # Browsed the site as them. Deduped per window by the default event model:
6
+ # the question a log answers is "did someone look, and when", not "how many
7
+ # requests did they issue".
8
+ READ = "impersonated_read".freeze
9
+
10
+ # Lifecycle. Discrete facts, never deduped: two sessions an hour apart are
11
+ # two rows.
12
+ START = "impersonation_start".freeze
13
+ STOP = "impersonation_stop".freeze
14
+ EXPIRED = "impersonation_expired".freeze
15
+ REVOKED = "impersonation_revoked".freeze
16
+ REFUSED = "impersonation_refused".freeze
17
+
18
+ ALL = [ READ, START, STOP, EXPIRED, REVOKED, REFUSED ].freeze
19
+ DISCRETE = [ START, STOP, EXPIRED, REVOKED, REFUSED ].freeze
20
+
21
+ # Phrasing for the account owner's own view of the log. Plain on purpose:
22
+ # the person reading it did not build this and should not have to guess
23
+ # what "impersonated_read" was supposed to mean.
24
+ DESCRIPTIONS = {
25
+ READ => "Viewed your pages as you see them",
26
+ START => "Started viewing your account as you see it",
27
+ STOP => "Stopped viewing your account",
28
+ EXPIRED => "Stopped viewing your account (time limit reached)",
29
+ REVOKED => "Stopped viewing your account (permission ended)",
30
+ REFUSED => "Tried to view your account and was refused"
31
+ }.freeze
32
+ end
33
+ end
@@ -0,0 +1,3 @@
1
+ module ViewingAs
2
+ VERSION = "0.1.0".freeze
3
+ end
data/lib/viewing_as.rb ADDED
@@ -0,0 +1,35 @@
1
+ require "active_support"
2
+ require "active_support/core_ext/numeric/time"
3
+ require "active_support/current_attributes"
4
+
5
+ require "viewing_as/version"
6
+ require "viewing_as/configuration"
7
+ require "viewing_as/kinds"
8
+ require "viewing_as/current"
9
+ require "viewing_as/current_user"
10
+ require "viewing_as/controller"
11
+ require "viewing_as/cable"
12
+ require "viewing_as/engine" if defined?(Rails::Engine)
13
+
14
+ # Viewing an account as its owner, without being able to change it.
15
+ #
16
+ # The whole library is a controller concern (ViewingAs::Controller), a mixin for
17
+ # the host's Current (ViewingAs::CurrentUser), an event model (ViewingAs::Event)
18
+ # and a configuration object holding every decision that belongs to the host
19
+ # application. See README.md.
20
+ module ViewingAs
21
+ class << self
22
+ def config
23
+ @config ||= Configuration.new
24
+ end
25
+
26
+ def configure
27
+ yield config
28
+ end
29
+
30
+ # For tests. Throws away every setting, including the host's.
31
+ def reset_config!
32
+ @config = Configuration.new
33
+ end
34
+ end
35
+ end
metadata ADDED
@@ -0,0 +1,124 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: viewing_as
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ed
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activerecord
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: actionpack
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.2'
54
+ - !ruby/object:Gem::Dependency
55
+ name: activesupport
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '7.2'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '7.2'
68
+ description: |
69
+ Let an administrator view an account as its owner. Read-only by default (two
70
+ layers: no non-GET requests, and ActiveRecord writes refused), re-validated
71
+ from the database on every request so revoked consent bites on the next click,
72
+ time-boxed server-side, and logged in words the account owner can read.
73
+ Stores its state in a signed cookie rather than the Rails session, so an
74
+ edge-cached site stays cached. Built for the Rails 8 authentication
75
+ generator; works with anything that can answer "who is signed in".
76
+ executables: []
77
+ extensions: []
78
+ extra_rdoc_files: []
79
+ files:
80
+ - CHANGELOG.md
81
+ - LICENSE.txt
82
+ - README.md
83
+ - app/models/viewing_as/event.rb
84
+ - app/views/viewing_as/_banner.html.erb
85
+ - lib/generators/viewing_as/install/install_generator.rb
86
+ - lib/generators/viewing_as/install/templates/current.rb
87
+ - lib/generators/viewing_as/install/templates/impersonations_controller.rb
88
+ - lib/generators/viewing_as/install/templates/initializer.rb
89
+ - lib/generators/viewing_as/install/templates/migration.rb.tt
90
+ - lib/viewing_as.rb
91
+ - lib/viewing_as/cable.rb
92
+ - lib/viewing_as/configuration.rb
93
+ - lib/viewing_as/controller.rb
94
+ - lib/viewing_as/current.rb
95
+ - lib/viewing_as/current_user.rb
96
+ - lib/viewing_as/engine.rb
97
+ - lib/viewing_as/kinds.rb
98
+ - lib/viewing_as/version.rb
99
+ homepage: https://github.com/delistmydata/viewing_as
100
+ licenses:
101
+ - MIT
102
+ metadata:
103
+ source_code_uri: https://github.com/delistmydata/viewing_as
104
+ changelog_uri: https://github.com/delistmydata/viewing_as/blob/main/CHANGELOG.md
105
+ bug_tracker_uri: https://github.com/delistmydata/viewing_as/issues
106
+ rdoc_options: []
107
+ require_paths:
108
+ - lib
109
+ required_ruby_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '3.3'
114
+ required_rubygems_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ requirements: []
120
+ rubygems_version: 4.0.16
121
+ specification_version: 4
122
+ summary: 'Impersonation for the Rails 8 authentication generator: read-only by default,
123
+ consented, time-boxed, logged'
124
+ test_files: []