Skip to content

Commit 2aeea0a

Browse files
committed
Preserve component authorization
1 parent 42a1091 commit 2aeea0a

10 files changed

Lines changed: 63 additions & 20 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,9 +247,9 @@ Turbo Frame requests the component with normal cookies. Only scalar targets
247247
that the server rendered into this `solid_object` scope are signed into its
248248
stream token and receive value payloads; component-only dependencies do not
249249
send their values to the browser. The endpoint renders the latest committed
250-
snapshot, returns `private, no-store`, and reauthorizes every declared
251-
dependency. Two viewers can therefore receive different HTML for the same
252-
actor without sharing either projection.
250+
snapshot, returns `private, no-store`, and reauthorizes the component name plus
251+
every declared dependency. Two viewers can therefore receive different HTML
252+
for the same actor without sharing either projection.
253253

254254
Reconnect compares the component's signed initial revision with the latest
255255
actor incarnation and state revision, then refreshes stale components. Cable

docs/architecture.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -507,10 +507,11 @@ Turbo Frame whose source is the signed engine endpoint.
507507

508508
The browser then makes an ordinary cookie-bearing HTTP request. The engine
509509
controller derives a request-specific context through
510-
`component_authorization_context`, calls `authorize_query` for every declared
511-
dependency, renders the host partial from a new committed snapshot, and returns
512-
`private, no-store` HTML. Subscribers to the same actor can therefore receive
513-
different HTML without sharing it through Cable or the database.
510+
`component_authorization_context`, calls `authorize_query` for the component
511+
name and every declared dependency, renders the host partial from a new
512+
committed snapshot, and returns `private, no-store` HTML. Subscribers to the
513+
same actor can therefore receive different HTML without sharing it through
514+
Cable or the database.
514515

515516
Each channel subscription transmits current scalar replacements and compares
516517
each component's signed initial revision against the latest committed

docs/authorization.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ three boundaries:
3030
2. `ActorChannel` passes its authenticated `ActionCable::Connection` to
3131
`authorize_subscription`.
3232
3. `ComponentsController` resolves a fresh context for the cookie-bearing HTTP
33-
request and calls `authorize_query` for every component dependency.
33+
request and calls `authorize_query` for the component name and every
34+
dependency.
3435

3536
Configure the refresh resolver when the query policy expects a user or service
3637
principal rather than the engine controller:

docs/correctness.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ explicit dependencies, initial instance ID and revision, and same-origin
129129
refresh path. The signature detects modification but grants no access. Initial
130130
rendering invokes query authorization, Cable separately invokes subscription
131131
authorization, and the cookie-bearing refresh request invokes query
132-
authorization again for every dependency.
132+
authorization again for the component name and every dependency.
133133

134134
The actor stream token separately signs the scalar observable targets rendered
135135
into its scope. A component dependency that has no scalar target carries only

docs/realtime.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,9 @@ approval.
6161
Initial scalar and component reads call `authorize_query` with the context
6262
passed to `solid_object`. The refresh controller resolves a new request context
6363
through `component_authorization_context`, then calls `authorize_query` again
64-
for every declared dependency. The default resolver supplies the engine
65-
controller; applications commonly resolve it to `Current.user`:
64+
for the component name and every declared dependency. The default resolver
65+
supplies the engine controller; applications commonly resolve it to
66+
`Current.user`:
6667

6768
```ruby
6869
configuration.component_authorization_context = ->(controller:) { Current.user }

lib/generators/solid_objects/templates/solid_objects.rb

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,8 @@
4949
configuration.authorize_subscription = ->(**) { false }
5050
configuration.authorize_administration = ->(**) { false }
5151

52-
# Reactive component refreshes run in the engine controller after a Cable
53-
# invalidation. Resolve that controller to the same authenticated principal
54-
# used by the initial view render. The query policy runs again for every
55-
# declared component dependency:
56-
#
57-
# configuration.component_authorization_context = lambda do |controller:|
58-
# Current.user
59-
# end
52+
# Configure component_authorization_context to return the authenticated
53+
# principal used for reactive component refreshes.
6054

6155
# On hosts where shell access is already an authenticated administrative
6256
# boundary, this enables only gem commands that pass the CLI context:

lib/solid_objects/component_renderer.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ def initialize(
2525

2626
# @rbs () -> untyped
2727
def call
28-
dependencies.each { |dependency| authorize_read!(dependency) }
28+
[ component_name, *dependencies ].uniq.each do |authorization_name|
29+
authorize_read!(authorization_name)
30+
end
2931
actor = ComponentView.new(
3032
snapshot:,
3133
dependencies:,

test/integration/actor_helper_test.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ def close
5252
<p>Open</p>
5353
<% end %>
5454
ERB
55+
"actors/actor_helper_test/cart_actor/_summary.html.erb" => <<~ERB,
56+
<p><%= actor.items.length %> items</p>
57+
ERB
5558
"actors/actor_helper_test/cart_actor/_leaky.html.erb" => <<~ERB
5659
<p><%= actor.status %></p>
5760
ERB
@@ -177,6 +180,26 @@ def close
177180
end
178181
end
179182

183+
test "authorizes a reactive component name before its dependencies" do
184+
authorization_calls = []
185+
SolidObjects.configuration.authorize_query = lambda do |**arguments|
186+
authorization_calls << arguments
187+
arguments.fetch(:message_name) == "items"
188+
end
189+
190+
assert_raises(SolidObjects::Unauthorized) do
191+
solid_object(
192+
CartActor.ref("alice"),
193+
authorization_context: "alice"
194+
) do |actor|
195+
actor.component(:summary, observes: :items)
196+
end
197+
end
198+
199+
assert_equal [ "summary" ],
200+
authorization_calls.map { |arguments| arguments.fetch(:message_name) }
201+
end
202+
180203
test "rejects observable reads omitted from component dependencies" do
181204
error = assert_raises(SolidObjects::UnknownComponentDependency) do
182205
solid_object(CartActor.ref("alice")) do |actor|

test/integration/components_controller_test.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,26 @@ def update_room(messages:, status:)
139139
assert_empty @response.body
140140
end
141141

142+
test "reauthorizes the component name before refresh dependencies" do
143+
reference = RoomActor.ref("general")
144+
token = component_token(
145+
reference,
146+
component_name: "messages",
147+
dependencies: %w[recent_messages]
148+
)
149+
authorization_calls = []
150+
SolidObjects.configuration.authorize_query = lambda do |**arguments|
151+
authorization_calls << arguments
152+
arguments.fetch(:message_name) == "recent_messages"
153+
end
154+
155+
render_component(token, viewer: "alice")
156+
157+
assert_response :forbidden
158+
assert_equal [ "messages" ],
159+
authorization_calls.map { |arguments| arguments.fetch(:message_name) }
160+
end
161+
142162
test "renders personalized HTML independently for each authorized request" do
143163
reference = RoomActor.ref("general")
144164
token = component_token(

test/integration/install_generator_test.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class InstallGeneratorTest < ActiveSupport::TestCase
2222
assert_includes initializer, "instance_retention_by_actor_type"
2323
assert_includes initializer, "authorization_context[:source] == \"cli\""
2424
assert_includes initializer, "component_authorization_context"
25+
refute_includes initializer, "component_authorization_context = lambda"
2526
assert_equal 5, initializer.scan("= ->(**) { false }").length
2627
end
2728
end

0 commit comments

Comments
 (0)