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 +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +196 -0
- data/app/models/viewing_as/event.rb +74 -0
- data/app/views/viewing_as/_banner.html.erb +21 -0
- data/lib/generators/viewing_as/install/install_generator.rb +101 -0
- data/lib/generators/viewing_as/install/templates/current.rb +6 -0
- data/lib/generators/viewing_as/install/templates/impersonations_controller.rb +38 -0
- data/lib/generators/viewing_as/install/templates/initializer.rb +51 -0
- data/lib/generators/viewing_as/install/templates/migration.rb.tt +20 -0
- data/lib/viewing_as/cable.rb +24 -0
- data/lib/viewing_as/configuration.rb +131 -0
- data/lib/viewing_as/controller.rb +248 -0
- data/lib/viewing_as/current.rb +16 -0
- data/lib/viewing_as/current_user.rb +49 -0
- data/lib/viewing_as/engine.rb +8 -0
- data/lib/viewing_as/kinds.rb +33 -0
- data/lib/viewing_as/version.rb +3 -0
- data/lib/viewing_as.rb +35 -0
- metadata +124 -0
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,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
|
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: []
|