solid_objects 0.12.1 → 0.13.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +58 -11
  4. data/docs/adr/0009-realtime-updates.md +5 -1
  5. data/docs/architecture.md +3 -2
  6. data/docs/authorization.md +11 -4
  7. data/docs/correctness.md +3 -3
  8. data/docs/dashboard.md +200 -0
  9. data/docs/database-schema.md +5 -4
  10. data/docs/realtime.md +22 -14
  11. data/docs/roadmap.md +24 -6
  12. data/docs/security.md +7 -7
  13. data/examples/application/README.md +5 -5
  14. data/examples/application/app/actors/chat_room_actor.rb +1 -1
  15. data/examples/application/app/actors/shopping_cart_actor.rb +2 -2
  16. data/lib/solid_objects/actor.rb +1 -1
  17. data/lib/solid_objects/version.rb +1 -1
  18. data/lib/solid_objects/web/action.rb +109 -0
  19. data/lib/solid_objects/web/application.rb +239 -0
  20. data/lib/solid_objects/web/csrf_protection.rb +130 -0
  21. data/lib/solid_objects/web/helpers.rb +227 -0
  22. data/lib/solid_objects/web/paginator.rb +64 -0
  23. data/lib/solid_objects/web/route.rb +55 -0
  24. data/lib/solid_objects/web/router.rb +46 -0
  25. data/lib/solid_objects/web/statistics.rb +78 -0
  26. data/lib/solid_objects/web.rb +222 -0
  27. data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
  28. data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
  29. data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
  30. data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
  31. data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
  32. data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
  33. data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
  34. data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
  35. data/sig/generated/lib/solid_objects/web.rbs +129 -0
  36. data/web/assets/javascripts/application.js +118 -0
  37. data/web/assets/javascripts/charts.js +190 -0
  38. data/web/assets/stylesheets/application.css +527 -0
  39. data/web/views/_messages.erb +29 -0
  40. data/web/views/_navigation.erb +15 -0
  41. data/web/views/_paging.erb +17 -0
  42. data/web/views/_status_filter.erb +8 -0
  43. data/web/views/_summary.erb +39 -0
  44. data/web/views/broadcasts.erb +35 -0
  45. data/web/views/dashboard.erb +128 -0
  46. data/web/views/dead_letter.erb +40 -0
  47. data/web/views/dead_letters.erb +42 -0
  48. data/web/views/effects.erb +35 -0
  49. data/web/views/instance.erb +139 -0
  50. data/web/views/instances.erb +49 -0
  51. data/web/views/layout.erb +22 -0
  52. data/web/views/mailbox.erb +35 -0
  53. data/web/views/message.erb +34 -0
  54. data/web/views/processes.erb +37 -0
  55. data/web/views/reminders.erb +37 -0
  56. metadata +55 -2
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.12.1"
4
+ VERSION = "0.13.0"
5
5
  end
@@ -0,0 +1,109 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "erb"
4
+ require "rack/request"
5
+ require "rack/utils"
6
+
7
+ module SolidObjects
8
+ class Web
9
+ # One request. A route handler runs inside an instance of this class, so a
10
+ # handler and the template it renders share the same helpers and the same
11
+ # request.
12
+ class Action
13
+ include Helpers
14
+
15
+ # @rbs @env: Hash[String, untyped]
16
+ # @rbs @route: Route
17
+ # @rbs @captures: Hash[Symbol, String?]
18
+ # @rbs @request: untyped
19
+ # @rbs @layout_rendered: bool
20
+ # @rbs @response_status: Integer
21
+
22
+ attr_reader :env, :route, :response_status
23
+
24
+ # @rbs (env: Hash[String, untyped], route: Route) -> void
25
+ def initialize(env:, route:)
26
+ @env = env
27
+ @route = route
28
+ @captures = route.capture(env["PATH_INFO"].to_s)
29
+ @layout_rendered = false
30
+ @response_status = 200
31
+ end
32
+
33
+ # Sets the status a rendered page is served with. `halt` and `redirect`
34
+ # stop the handler, so a page that must render its own body and still
35
+ # report a failure needs this instead.
36
+ # @rbs (Integer) -> void
37
+ def status(code)
38
+ @response_status = code
39
+ end
40
+
41
+ # @rbs () -> untyped
42
+ def request
43
+ @request ||= ::Rack::Request.new(env)
44
+ end
45
+
46
+ # @rbs () -> Hash[untyped, untyped]?
47
+ def session
48
+ env["rack.session"]
49
+ end
50
+
51
+ # @rbs (Symbol) -> String?
52
+ def route_params(key)
53
+ @captures[key]
54
+ end
55
+
56
+ # @rbs (String) -> untyped
57
+ def url_params(key)
58
+ request.params[key]
59
+ end
60
+
61
+ # @rbs () -> untyped
62
+ def call
63
+ instance_exec(&route.handler)
64
+ end
65
+
66
+ # The layout is rendered once per request. The flag is raised before the
67
+ # page body runs so a partial the body renders returns its own fragment
68
+ # rather than a second whole page. The layout reads the page it wraps
69
+ # from `locals.fetch(:content)`.
70
+ # @rbs (Symbol, ?Hash[Symbol, untyped]) -> String
71
+ def erb(name, locals = {})
72
+ return evaluate(Web.template(name), locals) if @layout_rendered
73
+
74
+ @layout_rendered = true
75
+ content = evaluate(Web.template(name), locals)
76
+ evaluate(Web.template(:layout), { content: })
77
+ end
78
+
79
+ # @rbs (Integer, ?String) -> void
80
+ def halt(status, body = ::Rack::Utils::HTTP_STATUS_CODES.fetch(status, "Error"))
81
+ throw :halt, [ status, { "content-type" => "text/plain" }, [ body ] ]
82
+ end
83
+
84
+ # @rbs (String) -> void
85
+ def redirect(path)
86
+ throw :halt, [ 302, { "location" => path_to(path) }, [] ]
87
+ end
88
+
89
+ # @rbs (untyped) -> void
90
+ def json(payload)
91
+ throw :halt, [
92
+ 200,
93
+ { "content-type" => "application/json", "cache-control" => "private, no-store" },
94
+ [ JSON.generate(payload) ]
95
+ ]
96
+ end
97
+
98
+ private
99
+
100
+ # `locals` is a local variable of this method, so a template reads it by
101
+ # name, and every helper is reachable because the template runs against
102
+ # this object.
103
+ # @rbs (untyped, Hash[Symbol, untyped]) -> String
104
+ def evaluate(template, locals)
105
+ template.result(binding)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,239 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Web
5
+ # The pages. Each route states the administration policy it needs, and
6
+ # `call` asks that policy before the handler runs, so a page cannot read
7
+ # the actor tables on behalf of an unauthorized request.
8
+ class Application
9
+ extend Router
10
+
11
+ SCRIPT_PLACEHOLDER = "!script-src!"
12
+ CONTENT_SECURITY_POLICY = [
13
+ "default-src 'self'",
14
+ "base-uri 'self'",
15
+ "form-action 'self'",
16
+ "frame-ancestors 'none'",
17
+ "img-src 'self' data:",
18
+ "style-src 'self'",
19
+ "script-src #{SCRIPT_PLACEHOLDER}",
20
+ "connect-src 'self'",
21
+ "object-src 'none'"
22
+ ].join("; ").freeze
23
+
24
+ MAILBOX_MEMBERSHIPS = %w[ready claimed].freeze
25
+ RECENT_LIMIT = 10
26
+ CHART_TYPE_LIMIT = 12
27
+
28
+ head "/", policy: { action: "index", resource: "dashboard" } do
29
+ # The cheapest liveness check available: it proves the dashboard can
30
+ # reach the database the actors run on, and returns no body.
31
+ ReadyMessage.count
32
+ ""
33
+ end
34
+
35
+ get "/", policy: { action: "index", resource: "dashboard" } do
36
+ @statistics = statistics.to_h
37
+ @processes = Process.order(last_heartbeat_at: :desc).limit(RECENT_LIMIT)
38
+ @dead_letters = DeadLetter.order(last_failed_at: :desc, id: :desc).limit(RECENT_LIMIT)
39
+ # The only chart that costs its own query, and the only one the poller
40
+ # cannot refresh, because the other two read what `/stats` already
41
+ # returns. Bounded so a runtime with many actor types draws a readable
42
+ # chart rather than every type it has ever seen.
43
+ @instances_by_type = Instance
44
+ .group(:actor_type)
45
+ .order(Arel.sql("COUNT(*) DESC"))
46
+ .limit(CHART_TYPE_LIMIT)
47
+ .count
48
+ erb(:dashboard)
49
+ end
50
+
51
+ get "/stats", policy: { action: "index", resource: "dashboard" } do
52
+ json(statistics.to_h)
53
+ end
54
+
55
+ get "/instances", policy: { action: "index", resource: "instances" } do
56
+ @paginator = paginate(filtered_instances)
57
+ # Suggestions come from the registry rather than a DISTINCT over the
58
+ # instances table, which no adapter can answer from an index. The field
59
+ # stays free text, so an actor type that is no longer registered is
60
+ # still reachable.
61
+ @actor_types = SolidObjects.registry.to_h.keys.sort
62
+ erb(:instances)
63
+ end
64
+
65
+ get "/instances/:id", policy: { action: "show", resource: "instances" } do
66
+ @instance = find_instance
67
+ @ready_messages = @instance.messages
68
+ .where(id: ReadyMessage.select(:message_id))
69
+ .order(sequence: :asc)
70
+ .limit(RECENT_LIMIT)
71
+ @claimed_messages = @instance.messages
72
+ .where(id: ClaimedMessage.select(:message_id))
73
+ .order(sequence: :asc)
74
+ .limit(RECENT_LIMIT)
75
+ @recent_messages = @instance.messages.order(sequence: :desc).limit(RECENT_LIMIT)
76
+ @reminders = Reminder.where(instance_id: @instance.id).order(next_run_at: :asc).limit(RECENT_LIMIT)
77
+ @effects = Effect.where(instance_id: @instance.id).order(id: :desc).limit(RECENT_LIMIT)
78
+ @broadcasts = Broadcast.where(instance_id: @instance.id).order(id: :desc).limit(RECENT_LIMIT)
79
+ @dead_letters = DeadLetter.where(instance_id: @instance.id).order(last_failed_at: :desc).limit(RECENT_LIMIT)
80
+ erb(:instance)
81
+ end
82
+
83
+ # Pausing stops the activation manager from claiming the instance again.
84
+ # A pass already in flight finishes its turn, and a synchronous caller
85
+ # waiting on this instance times out rather than being answered, so this
86
+ # is an operator brake and not a delivery guarantee.
87
+ post "/instances/:id/pause", policy: { action: "pause", resource: "instances" } do
88
+ instance = find_instance
89
+ instance.update!(paused_at: SolidObjects.database_adapter.database_now)
90
+ redirect("/instances/#{instance.id}")
91
+ end
92
+
93
+ post "/instances/:id/resume", policy: { action: "resume", resource: "instances" } do
94
+ instance = find_instance
95
+ instance.update!(paused_at: nil)
96
+ redirect("/instances/#{instance.id}")
97
+ end
98
+
99
+ get "/mailbox", policy: { action: "index", resource: "messages" } do
100
+ @membership = filter_value(MAILBOX_MEMBERSHIPS, default: "ready")
101
+ @paginator = paginate(mailbox_messages(@membership))
102
+ erb(:mailbox)
103
+ end
104
+
105
+ get "/messages/:id", policy: { action: "show", resource: "messages" } do
106
+ @message = Message.find_by(id: route_params(:id))
107
+ halt(404) unless @message
108
+ erb(:message)
109
+ end
110
+
111
+ get "/reminders", policy: { action: "index", resource: "reminders" } do
112
+ @status = filter_value(Statistics::REMINDER_STATUSES)
113
+ relation = Reminder.order(next_run_at: :asc, id: :asc)
114
+ @paginator = paginate(@status ? relation.where(status: @status) : relation)
115
+ erb(:reminders)
116
+ end
117
+
118
+ get "/effects", policy: { action: "index", resource: "effects" } do
119
+ @status = filter_value(Statistics::EFFECT_STATUSES)
120
+ relation = Effect.order(id: :desc)
121
+ @paginator = paginate(@status ? relation.where(status: @status) : relation)
122
+ erb(:effects)
123
+ end
124
+
125
+ get "/broadcasts", policy: { action: "index", resource: "broadcasts" } do
126
+ @status = filter_value(Statistics::BROADCAST_STATUSES)
127
+ relation = Broadcast.order(id: :desc)
128
+ @paginator = paginate(@status ? relation.where(status: @status) : relation)
129
+ erb(:broadcasts)
130
+ end
131
+
132
+ get "/dead_letters", policy: { action: "index", resource: "dead_letters" } do
133
+ @paginator = paginate(DeadLetter.order(last_failed_at: :desc, id: :desc))
134
+ erb(:dead_letters)
135
+ end
136
+
137
+ get "/dead_letters/:id", policy: { action: "show", resource: "dead_letters" } do
138
+ @dead_letter = DeadLetter.find_by(id: route_params(:id))
139
+ halt(404) unless @dead_letter
140
+ erb(:dead_letter)
141
+ end
142
+
143
+ # A retry re-enters the mailbox, which refuses work the runtime cannot
144
+ # accept: an actor class that no longer exists, a full mailbox, a payload
145
+ # over the cap. The operator who pressed the button is told which,
146
+ # instead of being handed a bare 500 from the Rack handler.
147
+ post "/dead_letters/:id/retry", policy: { action: "retry", resource: "dead_letters" } do
148
+ SolidObjects.dead_letters.retry(route_params(:id).to_i, authorization_context: self)
149
+ redirect("/dead_letters")
150
+ rescue Unauthorized
151
+ raise
152
+ rescue SolidObjects::Error => error
153
+ @dead_letter = DeadLetter.find_by(id: route_params(:id))
154
+ halt(404) unless @dead_letter
155
+ @error = "#{error.class.name.split("::").last}: #{error.message}"
156
+ status(422)
157
+ erb(:dead_letter)
158
+ end
159
+
160
+ get "/processes", policy: { action: "index", resource: "processes" } do
161
+ @status = filter_value(Statistics::PROCESS_STATES)
162
+ relation = Process.order(last_heartbeat_at: :desc)
163
+ @paginator = paginate(@status ? relation.where(shutdown_state: @status) : relation)
164
+ # Counted in one grouped query rather than once per row, because this
165
+ # page is read while the runtime is already under load.
166
+ @activated_counts = Instance
167
+ .where(activation_owner_id: @paginator.records.map(&:id))
168
+ .group(:activation_owner_id)
169
+ .count
170
+ erb(:processes)
171
+ end
172
+
173
+ # @rbs (Hash[String, untyped]) -> Array[untyped]
174
+ def call(env)
175
+ route = self.class.match(env["REQUEST_METHOD"].to_s, env["PATH_INFO"].to_s)
176
+ return not_found unless route
177
+
178
+ action = Action.new(env:, route:)
179
+ return forbidden unless authorized?(action)
180
+
181
+ respond(action, catch(:halt) { action.call })
182
+ rescue Unauthorized
183
+ forbidden
184
+ end
185
+
186
+ private
187
+
188
+ # @rbs (Action, untyped) -> Array[untyped]
189
+ def respond(action, result)
190
+ return result if result.is_a?(Array)
191
+
192
+ [ action.response_status, page_headers(action.env), [ result.to_s ] ]
193
+ end
194
+
195
+ # @rbs (Action) -> bool
196
+ def authorized?(action)
197
+ policy = action.route.policy
198
+ SolidObjects.configuration.authorize_administration.call(
199
+ action: policy.fetch(:action),
200
+ resource: policy.fetch(:resource),
201
+ resource_id: action.route_params(:id),
202
+ authorization_context: action
203
+ )
204
+ end
205
+
206
+ # @rbs (Hash[String, untyped]) -> Hash[String, String]
207
+ def page_headers(env)
208
+ {
209
+ "content-type" => "text/html; charset=utf-8",
210
+ "cache-control" => "private, no-store",
211
+ "content-security-policy" => content_security_policy(env),
212
+ "x-content-type-options" => "nosniff",
213
+ "referrer-policy" => "same-origin"
214
+ }
215
+ end
216
+
217
+ # The chart host is named only when one is configured, so a deployment
218
+ # that vendors the library or turns charts off never advertises a third
219
+ # party origin it does not use.
220
+ # @rbs (Hash[String, untyped]) -> String
221
+ def content_security_policy(env)
222
+ sources = [ "'self'", "'nonce-#{env[Web::NONCE_KEY]}'", Web.chart_library_origin ].compact
223
+ CONTENT_SECURITY_POLICY.sub(SCRIPT_PLACEHOLDER, sources.join(" "))
224
+ end
225
+
226
+ # The cascade header lets a host application serve its own 404 for a path
227
+ # below the mount that the dashboard does not define.
228
+ # @rbs () -> Array[untyped]
229
+ def not_found
230
+ [ 404, { "content-type" => "text/plain", "x-cascade" => "pass" }, [ "Not Found" ] ]
231
+ end
232
+
233
+ # @rbs () -> Array[untyped]
234
+ def forbidden
235
+ [ 403, { "content-type" => "text/plain" }, [ "Forbidden" ] ]
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,130 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "rack/request"
4
+ require "rack/utils"
5
+ require "securerandom"
6
+
7
+ module SolidObjects
8
+ class Web
9
+ # A state changing request must carry the token of the session that asked
10
+ # for the form. The token a form receives is masked with a fresh one-time
11
+ # pad on every request, so the bytes on the wire differ each time and a
12
+ # compression side channel cannot recover the session token.
13
+ class CsrfProtection
14
+ SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
15
+ TOKEN_BYTES = 32
16
+
17
+ MISSING_SESSION = <<~MESSAGE
18
+ SolidObjects::Web needs a Rack session for CSRF protection.
19
+
20
+ Mount it inside the application routes so the Rails session middleware runs first:
21
+
22
+ Rails.application.routes.draw do
23
+ mount SolidObjects::Web => "/solid_objects"
24
+ end
25
+
26
+ In a bare Rack application, run a session middleware before it:
27
+
28
+ use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: true
29
+ run SolidObjects::Web
30
+ MESSAGE
31
+
32
+ # @rbs (untyped) -> void
33
+ def initialize(app)
34
+ @app = app
35
+ end
36
+
37
+ # @rbs (Hash[String, untyped]) -> Array[untyped]
38
+ def call(env)
39
+ return forbidden unless accept?(env)
40
+
41
+ session = session!(env)
42
+ session[:csrf] ||= SecureRandom.base64(TOKEN_BYTES)
43
+ env[Web::CSRF_TOKEN_KEY] = mask(session[:csrf])
44
+ @app.call(env)
45
+ end
46
+
47
+ private
48
+
49
+ # @rbs (Hash[String, untyped]) -> bool
50
+ def accept?(env)
51
+ return true if SAFE_METHODS.include?(env["REQUEST_METHOD"])
52
+
53
+ valid?(env, ::Rack::Request.new(env).params["authenticity_token"])
54
+ end
55
+
56
+ # @rbs (Hash[String, untyped], String?) -> bool
57
+ def valid?(env, given)
58
+ return false if given.nil? || given.empty?
59
+
60
+ session = session!(env)
61
+ stored = session[:csrf]
62
+ return false if stored.nil?
63
+
64
+ token = decode(given)
65
+ return false unless token
66
+
67
+ # The secret is not rotated here. A page renders one Retry form per
68
+ # dead letter, and a browser keeps pages open in other tabs, so
69
+ # spending the secret on the first submission would answer 403 to
70
+ # every other form already rendered. Single use is not what a CSRF
71
+ # token provides: it proves the request came from a page this session
72
+ # was served, and the per-request mask below is what keeps the value
73
+ # on the wire from repeating.
74
+ matches?(token, stored)
75
+ end
76
+
77
+ # @rbs (String, String) -> bool
78
+ def matches?(token, stored)
79
+ candidate = case token.bytesize
80
+ when TOKEN_BYTES then token
81
+ when TOKEN_BYTES * 2 then unmask(token)
82
+ else return false
83
+ end
84
+
85
+ ::Rack::Utils.secure_compare(candidate, decode(stored).to_s)
86
+ end
87
+
88
+ # @rbs (String) -> String
89
+ def mask(token)
90
+ decoded = decode(token).to_s
91
+ pad = SecureRandom.random_bytes(decoded.bytesize)
92
+ encode(pad + exclusive_or(pad, decoded))
93
+ end
94
+
95
+ # @rbs (String) -> String
96
+ def unmask(masked)
97
+ half = masked.bytesize / 2
98
+ exclusive_or(masked[0, half].to_s, masked[half..].to_s)
99
+ end
100
+
101
+ # @rbs (String, String) -> String
102
+ def exclusive_or(left, right)
103
+ left.bytes.zip(right.bytes).map { |first, second| first ^ second.to_i }.pack("c*")
104
+ end
105
+
106
+ # @rbs (String) -> String
107
+ def encode(token)
108
+ [ token ].pack("m0").tr("+/", "-_")
109
+ end
110
+
111
+ # @rbs (String) -> String?
112
+ def decode(token)
113
+ decoded = token.tr("-_", "+/").unpack1("m0")
114
+ decoded.is_a?(String) ? decoded : nil
115
+ rescue ArgumentError
116
+ nil
117
+ end
118
+
119
+ # @rbs (Hash[String, untyped]) -> Hash[untyped, untyped]
120
+ def session!(env)
121
+ env["rack.session"] || raise(MISSING_SESSION)
122
+ end
123
+
124
+ # @rbs () -> Array[untyped]
125
+ def forbidden
126
+ [ 403, { "content-type" => "text/plain" }, [ "Forbidden" ] ]
127
+ end
128
+ end
129
+ end
130
+ end