Skip to content

Echo: add session controllers, routes and socket-subscribe authorization (Slice 4) - #1651

Merged
dradis-bot merged 10 commits into
echo/add-sessionsfrom
echo/add-session-controllers
Aug 3, 2026
Merged

Echo: add session controllers, routes and socket-subscribe authorization (Slice 4)#1651
dradis-bot merged 10 commits into
echo/add-sessionsfrom
echo/add-session-controllers

Conversation

@etdsoft

@etdsoft etdsoft commented Jul 15, 2026

Copy link
Copy Markdown
Member

Summary

Factory Slice 4 of 5 — Echo Sessions ( SEC-475, milestone SEC-469). Stacked on Slice 3 (#1650, base branch echo/add-reply-job).

Purely additive — no changes to InteractionsController, its views, or its routes (that teardown is owned by a later ticket). This slice adds the session-based interaction surface and the socket re-authorization security fix.

What's added:

  • Routesresources :sessions, only: [:index, :show, :create] with nested resources :messages, only: [:create] under projects. interactions routes left exactly as they were.
  • Projects::SessionsController (AuthenticatedController + ProjectScoped, layout false):
    • create — builds a Session with Agents::Roslin.instance, current_user, and a title copied from the selected prompt. The Prompt is read only at the controller boundary — no FK is stored — and the lookup is scoped through for(@type) so the Prompt::SCOPES whitelist (enforced by set_type) is honoured. The first user Message carries the (Liquid-rendered, possibly edited) prompt text, then request_reply! fires. Responds with the session turbo frame.
    • show — transcript + composer; runs the check_turbo_config Redis ping and renders the "Redis is down" warning.
    • index — record-scoped session list; keeps the Roslin-disabled and prompts empty-state warnings.
  • Projects::Sessions::MessagesController#create — appends a user message and re-opens the reply gate via request_reply!.
  • Dradis::Plugins::Echo::SessionsChannel — a custom Turbo Streams channel (documented turbo-rails pattern). Turbo's default channel trusts any validly-signed stream name for its (non-expiring) lifetime, so a user who loses project access keeps receiving the transcript. This channel resolves the session behind the signed name and re-checks Ability.new(current_user).can?(:use, session.record.project) at subscribe time, rejecting revoked users. The view's turbo_stream_from gains channel: in Slice 5.
  • RecordScoping concern — resolves records through the current project's collections so cross-project/record access raises RecordNotFound (mirrors Projects::GrammarController).

Bug fixed (Slice 3 carryover): ReplyJob defined a private serialize(session, cutoff_id) that shadowed ActiveJob::Core#serialize — the method every queue adapter calls when enqueuing. perform_later (and therefore Session#request_reply!) raised NoMethodError at runtime; Slice 3 never caught it because its specs stubbed perform_later. This slice is the first to enqueue for real. Renamed the private method to finalize and added an enqueue-path guard. Committed separately for review.

Testing steps

  1. Enable the Roslin agent (Echo → Agents) and configure a provider.
  2. On an Issue, POST to project_sessions_path with type=issue, record=<issue id>, prompt_id=<saved prompt id>, and prompt=<edited text>. A new session + first user message are created, a reply is enqueued, and the response renders the session frame.
  3. POST a message to the nested messages route — a user message is appended and a reply is enqueued.
  4. Attempt either POST with an out-of-project record id — the request is denied (404).
  5. Subscribe to SessionsChannel for a session you may :use — accepted and streaming. Revoke :use on the project and re-subscribe — rejected.

Automated coverage: request specs (sessions + messages, record scoping + cross-record denial), a channel spec that flips authorization between subscribe attempts, and the interactions/grammar suites (unchanged, still green). Full engine suite: 169 examples, 0 failures.

Other Information

Local CI mirror all green: bundler-audit, ruby-audit, brakeman -w2 (0 warnings), rubocop (changed files, 0 offenses), rspec (engine suite).

I assign all rights, including copyright, to any future Dradis work by myself to Security Roots.

Check List

  • Added a CHANGELOG entry — N/A: no user-reachable behaviour yet (UI wired in Slice 5).
  • Commit message has a detailed description of what changed and why.

@dradis-bot
dradis-bot force-pushed the echo/add-reply-job branch from 7b1e2ad to 2be6905 Compare July 16, 2026 14:04
@dradis-bot
dradis-bot force-pushed the echo/add-session-controllers branch 2 times, most recently from 094abe5 to 4cecf56 Compare July 17, 2026 00:15
@dradis-bot
dradis-bot force-pushed the echo/add-reply-job branch from 2be6905 to 8467bbb Compare July 17, 2026 00:15
@dradis-bot
dradis-bot force-pushed the echo/add-session-controllers branch from 4cecf56 to 1e7c6bf Compare July 17, 2026 10:59
@etdsoft

etdsoft commented Jul 17, 2026

Copy link
Copy Markdown
Member Author

SEC-514 — PR #1655 code-review fixes (Slice 4: echo/add-session-controllers)

Applies the review findings introduced on the session-controllers slice (rebased onto the updated Slice 3 branch, force-pushed).

  • FileUpload - fix :destroy handler #7 Blank-prompt 500: sessions#create now saves without a bang and returns 422 on an invalid (blank) prompt, mirroring the messages endpoint, instead of raising RecordInvalid. (The deferred-reply behaviour from the base branch is preserved — no request_reply! on create.)
  • Evidence#show, Notes#show - use the right CSS :id for sidebar links (… #9 Dead sessions#index: dropped the unused :index action and route; nothing links to it (back-link and lazy frame use the interactions path). The dead index view is deleted in Slice 5a.
  • Api after action in rescue #10 check_turbo_config: extracted a shared TurboConfigCheck concern that guards on the cable adapter class (pings only the Redis adapter) and memoizes — async/test adapters no-op silently, so no spurious "can't contact Redis" alert and no per-request Redis round-trip. (Also adopted by interactions_controller in Slice 6.)
  • add new create evidence form to issues#show #11 (comment-only): tightened the SessionsChannel class comment to say the re-auth guards new subscriptions only.

Verification: sessions / replies / messages / channel / concern / reply-job specs green locally (33 examples); rubocop clean on changed files. Feature specs need QA (no Firefox locally).

@etdsoft etdsoft mentioned this pull request Jul 17, 2026
2 tasks
@etdsoft

etdsoft commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

@MattBudz — this PR (Echo Slice 4: add session controllers) has been open for >5 days without activity. Any blockers? Can we get this merged?

@dradis-bot
dradis-bot force-pushed the echo/add-reply-job branch from 9427a44 to 5996d2f Compare July 22, 2026 15:15
@dradis-bot
dradis-bot force-pushed the echo/add-session-controllers branch 2 times, most recently from efddc56 to de6bb67 Compare July 22, 2026 15:44
Comment thread engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb Outdated
@dradis-bot
dradis-bot force-pushed the echo/add-session-controllers branch from de6bb67 to 47a483e Compare July 24, 2026 16:26
private

def scoped_record(session)
collection = session.record_type == 'Issue' ? current_project.issues : current_project.notes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't cleanly extensible if we add other records in the future. What about

Suggested change
collection = session.record_type == 'Issue' ? current_project.issues : current_project.notes
record_type = session.record_type.underscore.pluralize
collection = current_project.public_send(record_type).find(session.record_id)

Comment on lines +43 to +44
record = GlobalID::Locator.locate(name.split(':').first)
record if record.is_a?(Session)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
record = GlobalID::Locator.locate(name.split(':').first)
record if record.is_a?(Session)
GlobalID::Locator.locate(name.split(':').first, only: Session)

https://www.rubydoc.info/gems/globalid/GlobalID/Locator#locate-class_method


def turbo_backend_reachable?
adapter = ActionCable.server.pubsub
return true unless adapter.is_a?(ActionCable::SubscriptionAdapter::Redis)

@caitmich caitmich Jul 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return true unless adapter.is_a?(ActionCable::SubscriptionAdapter::Redis)
return true unless adapter.respond_to?(:redis_connection_for_subscriptions)

What about checking that it responds rather than matching against the specific class name? More future-proof

# Appends a user turn and re-opens the reply gate. The new message
# broadcasts itself into the transcript, so there's nothing to render back.
def create
message = @session.messages.build(content: params[:content], role: :user, user: current_user)

@caitmich caitmich Jul 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a callback in the Message model that sets role to :user if a user is passed? That way we don't have to remember to pass both every time

# Appends a user turn and re-opens the reply gate. The new message
# broadcasts itself into the transcript, so there's nothing to render back.
def create
message = @session.messages.build(content: params[:content], role: :user, user: current_user)

@caitmich caitmich Jul 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we validate the content with something like params.expect?

params.expect(:content)

https://api.rubyonrails.org/classes/ActionController/Parameters.html#method-i-expect

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate, what?

private

def set_session
@session = Session.find(params[:session_id])

@caitmich caitmich Jul 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things are odd here:

  1. scoped_record(@session) isn't being assigned to anything, so I assume this is meant to throw an exception if it's not found? If that's the case, it's not super clear and maybe a comment or a more explicit way of handling this would be better?
  2. This method is called set_session but it's doing more than that, it's also validating the record. Can we split it or make it clearer what this does?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't set_ a common idiom in our controllers that set and validate form user params?


def set_session
@session = Session.find(params[:session_id])
scoped_record(@session)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since everywhere set_session is defined we're also validating the record via scoped_record why not include this in the concern and make it more general? Like HasSession or something like that

end

def set_record
@record = current_project.send(@type.to_s.pluralize).find(record_params[:record])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is overwritten on L67

# one. Roslin-disabled and prompts empty-state warnings are handled by the
# view.
def index
@sessions = Session.for_record(@record)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about renaming this scope to something like record_scoped? Session.for_record sounds like we're creating a session based on other naming conventions we have in the app like from_rtp etc

@etdsoft etdsoft Jul 31, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not certain a different name is better. "record scoped" signifies it's a scope for this record, which I think "for_record" kind of already does...

The minions also noticed:

same pattern as Notification.for_user

title: prompt.title,
user: current_user
)
@session.messages.build(content: params[:prompt], role: :user, user: current_user)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validate content?

<div class="list-group mb-3" id="echo-sessions" data-behavior="echo-sessions">
<% @sessions.each do |session| %>
<%= link_to session.title.presence || "Session ##{session.id}",
echo.project_session_path(current_project, session, type: @type, record: @record.id),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need both record id and type? Since we're validating type in the controller can we get one from the other?

private

def record_params
params.permit(:id, :prompt, :prompt_id, :project_id, :record, :type)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

record_id would be better since we're not passing the whole record

@@ -0,0 +1,34 @@
<turbo-frame id="<%= dom_id(@record, :echo) %>">
<% unless @turbo_status %>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about extracting this entire block to a shared partial and we can include it wherever we need in future with a single render partial call? I can foresee us needing this in more places down the line


<div id="echo-messages" data-behavior="echo-messages">
<%= render partial: 'dradis/plugins/echo/projects/sessions/messages/message',
collection: @session.messages.order(:created_at, :id), as: :message %>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not set @collection in the controller?


resources :grammar_corrections, only: [:create], controller: 'projects/grammar_corrections'
resources :grammar_suggestions, only: [:create], controller: 'projects/grammar_suggestions'

@caitmich caitmich Jul 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that we have so many controllers under projects/ it looks like it's time for something like

scope module: 'projects' do

Comment on lines +8 to +9
<%# Live transcript + composer state. Slice 5 adds `channel: SessionsChannel` %>
<%# so the socket re-authorizes on subscribe. %>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<%# Live transcript + composer state. Slice 5 adds `channel: SessionsChannel` %>
<%# so the socket re-authorizes on subscribe. %>
<%# Live transcript + composer state. %>

@dradis-bot
dradis-bot force-pushed the echo/add-reply-job branch from 66910f0 to 19b458f Compare July 31, 2026 12:10
@dradis-bot
dradis-bot force-pushed the echo/add-session-controllers branch from c34ab03 to 7ba7a71 Compare July 31, 2026 12:10
dradis-bot and others added 7 commits July 31, 2026 14:02
A private serialize(session, cutoff_id) overrode ActiveJob::Core#serialize, which every queue adapter calls when enqueuing. perform_later — and so Session#request_reply! — raised NoMethodError at runtime. Rename the private method to finalize and cover the enqueue path.
Purely additive Slice 4. Adds project-scoped Sessions/Messages controllers (Prompt read only at the controller boundary, no FK; Prompt::SCOPES whitelist honoured via set_type), their routes and index/show views, and SessionsChannel — a custom Turbo Streams channel that re-checks :use on the session's project at subscribe time and rejects revoked users, closing the non-expiring signed-stream-name hole. InteractionsController is untouched.
On initial session creation SessionsController#create called request_reply!
before render :show, so ReplyJob's streaming-container broadcast raced ahead of
the browser's SessionsChannel subscription; the container append was missed and
the later chunk/replace broadcasts hit a non-existent node, so the reply only
appeared on reload (SEC-506 Bug 4).

Restore the invariant "the streaming container is broadcast only to a listening
socket": create no longer starts generation. It renders show in the new
Session#reply_pending? state and a dedicated RepliesController#create (POST
sessions/:id/reply) starts generation, to be driven from the session Stimulus
controller once it has connected. reply_pending? (idle + newest message is a
user turn) guards the trigger so reconnects and extra viewers can't spawn an
unsolicited reply. Follow-up messages are unchanged — the client is already
subscribed there.

Refs SEC-506.
Applies PR #1655 review findings on the session controllers slice:

- Blank-prompt 500 (#7): sessions#create now saves without a bang and
  returns 422 on an invalid (blank) prompt, mirroring the messages
  endpoint, instead of raising RecordInvalid.
- Dead index (#9): drop the unused sessions#index action and route;
  nothing links to it (the back-link and lazy frame use the interactions
  path). The dead index view is removed in the views slice.
- check_turbo_config (#10): extract a shared TurboConfigCheck concern
  that pings only the Redis adapter and memoizes, so async/test adapters
  no-op silently — no spurious 'can't contact Redis' alert, no
  per-request round-trip.
- SessionsChannel (#11): tighten the class comment to say re-auth guards
  new subscriptions only (comment-only).
Ask the session for its project instead of reaching through
session.record.project. record is polymorphic, so the delegation is the right
seam for the authorization check and keeps callers decoupled from where a
session's project currently comes from.
@dradis-bot
dradis-bot force-pushed the echo/add-session-controllers branch 2 times, most recently from e43a0b9 to 7bc671b Compare July 31, 2026 13:10
Base automatically changed from echo/add-reply-job to echo/add-sessions August 3, 2026 14:29
@dradis-bot
dradis-bot merged commit 4bd83cf into echo/add-sessions Aug 3, 2026
2 checks passed
@dradis-bot
dradis-bot deleted the echo/add-session-controllers branch August 3, 2026 14:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants