-
Notifications
You must be signed in to change notification settings - Fork 227
Echo: add session controllers, routes and socket-subscribe authorization (Slice 4) #1651
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4f71959
002e235
1f5e42f
ecfd8c4
86d6d25
c6b6583
7bc671b
6c4141e
7c178fa
4bd83cf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| module Dradis::Plugins::Echo | ||
| # Custom Turbo Streams channel for Echo sessions. Turbo's default channel | ||
| # trusts any client holding a validly-signed stream name for the lifetime of | ||
| # that name — which never expires — so a user who loses project access could | ||
| # subscribe and keep receiving the transcript. We re-check authorization when | ||
| # a subscription is established: resolve the session behind the signed name | ||
| # and only stream if the subscriber may still :use its project, otherwise | ||
| # reject. Note this guards *new* subscriptions only; a connection opened while | ||
| # still authorized keeps streaming until it is torn down. | ||
| # | ||
| # Follows the documented turbo-rails custom-channel pattern | ||
| # (Turbo::StreamsChannel). | ||
| class SessionsChannel < ApplicationCable::Channel | ||
| extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName | ||
| include Turbo::Streams::StreamName::ClassMethods | ||
|
|
||
| def subscribed | ||
| session = session_from_stream_name | ||
|
|
||
| if session && authorized?(session) | ||
| stream_from verified_stream_name_from_params | ||
| else | ||
| reject | ||
| end | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def authorized?(session) | ||
| Ability.new(current_user).can?(:use, session.project) | ||
| end | ||
|
|
||
| # The signed name encodes `[session, :messages]`, i.e. | ||
| # "<session-gid-param>:messages". A tampered name fails verification and | ||
| # yields nil (never reaching locate), so the only raise path left is a | ||
| # session deleted between signing and subscribe — RecordNotFound, which we | ||
| # treat as unauthorized. Narrower than a blanket rescue so genuine bugs | ||
| # (NoMethodError etc.) still surface. | ||
| def session_from_stream_name | ||
| name = verified_stream_name_from_params | ||
| return unless name | ||
|
|
||
| GlobalID::Locator.locate(name.split(':').first, only: Session) | ||
| rescue ActiveRecord::RecordNotFound | ||
| nil | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| module Dradis::Plugins::Echo | ||
| # Loads the URL-nested session for the message/reply controllers and re-scopes | ||
| # it through the current project, so an out-of-scope session_id raises | ||
| # ActiveRecord::RecordNotFound rather than leaking another project's session. | ||
| # Pulls in RecordScoping for the scoped lookup; consumers only include this. | ||
| module HasSession | ||
| extend ActiveSupport::Concern | ||
| include RecordScoping | ||
|
|
||
| included do | ||
| before_action :set_session | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def set_session | ||
| @session = Session.find(params[:session_id]) | ||
| scoped_record(@session) | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| module Dradis::Plugins::Echo | ||
| # Shared record scoping for the session controllers. A session is always | ||
| # anchored to a record (an Issue or a Note) that must live inside the current | ||
| # project; resolving it through the project's collections gives us | ||
| # cross-project/record isolation for free — an out-of-scope id raises | ||
| # ActiveRecord::RecordNotFound, mirroring Projects::GrammarController. | ||
| # | ||
| # We read the session's stored record_type/record_id directly rather than | ||
| # loading session.record: the association would fire a polymorphic query just | ||
| # to re-scope through the project. Issues are persisted as 'Issue' even though | ||
| # they descend from Note (see Session#record=), so record_type maps straight | ||
| # onto the matching project collection (issues, notes, ...). | ||
| module RecordScoping | ||
| extend ActiveSupport::Concern | ||
|
|
||
| private | ||
|
|
||
| def scoped_record(session) | ||
| current_project.public_send(session.record_type.underscore.pluralize).find(session.record_id) | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| module Dradis::Plugins::Echo | ||
| # Shared `check_turbo_config` before_action for the streaming controllers. | ||
| # Turbo Streams only work if Action Cable can reach its backend, so the view | ||
| # can warn the user when it can't. | ||
| # | ||
| # Only a Redis-backed adapter needs a reachable external server, so we ping | ||
| # just those (duck-typed on the subscription connection) and memoize the | ||
| # result. Every other adapter (async in development, test in specs) is always | ||
| # treated as healthy — no spurious "can't contact Redis" alert and no | ||
| # per-request Redis round-trip. | ||
| module TurboConfigCheck | ||
| extend ActiveSupport::Concern | ||
|
|
||
| private | ||
|
|
||
| def check_turbo_config | ||
| return @turbo_status if defined?(@turbo_status) | ||
|
|
||
| @turbo_status = turbo_backend_reachable? | ||
| end | ||
|
|
||
| def turbo_backend_reachable? | ||
| adapter = ActionCable.server.pubsub | ||
| return true unless adapter.respond_to?(:redis_connection_for_subscriptions) | ||
|
|
||
| adapter.redis_connection_for_subscriptions.ping | ||
| true | ||
| rescue StandardError | ||
| false | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| module Dradis::Plugins::Echo | ||
| class Projects::Sessions::MessagesController < AuthenticatedController | ||
| include HasSession | ||
| include ProjectScoped | ||
| layout false | ||
|
|
||
| # 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.expect(:content), user: current_user) | ||
|
|
||
| if message.save | ||
| @session.request_reply! | ||
| head :ok | ||
| else | ||
| head :unprocessable_entity | ||
| end | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| module Dradis::Plugins::Echo | ||
| class Projects::Sessions::RepliesController < AuthenticatedController | ||
| include HasSession | ||
| include ProjectScoped | ||
| layout false | ||
|
|
||
| # Starts generation for a freshly-created session. The session Stimulus | ||
| # controller POSTs here from `connect`, i.e. after the browser has subscribed | ||
| # to the SessionsChannel, so the streaming container ReplyJob broadcasts lands | ||
| # on a listening socket (SEC-506 Bug 4). Guarded by reply_pending? so a | ||
| # reconnect, a second viewer, or a stray POST on an already-answered session | ||
| # can never spawn an unsolicited reply — request_reply! only fires while a | ||
| # reply is genuinely owed. | ||
| def create | ||
| @session.request_reply! if @session.reply_pending? | ||
| head :ok | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| module Dradis::Plugins::Echo | ||
| class Projects::SessionsController < AuthenticatedController | ||
| include EventPublisher | ||
| include ProjectScoped | ||
| include RecordScoping | ||
| include TurboConfigCheck | ||
| layout false | ||
|
|
||
| before_action :set_type, only: [:create, :index] | ||
| before_action :set_record, only: [:create, :index] | ||
| before_action :check_turbo_config, only: [:show, :create] | ||
| before_action :set_session, only: [:show] | ||
|
|
||
| # Lists the record's past sessions plus the prompts available to start a new | ||
| # one. Roslin-disabled and prompts empty-state warnings are handled by the | ||
| # view. | ||
| def index | ||
| @sessions = Session.for_record(@record) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about renaming this scope to something like
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
|
||
| @prompts = current_user.prompts.for(@type) | ||
| end | ||
|
|
||
| def show | ||
| @messages = @session.messages.order(:created_at, :id) | ||
| end | ||
|
|
||
| # Starts a conversation from a saved prompt. The Prompt is read only here, at | ||
| # the controller boundary — we copy its title and never store a FK, so a | ||
| # later edit or deletion of the template can't rewrite history. Scoping the | ||
| # lookup through `for(@type)` honours the Prompt::SCOPES whitelist. The first | ||
| # user Message carries the (Liquid-rendered, possibly edited) prompt text. | ||
| # | ||
| # We deliberately do NOT call request_reply! here: on initial creation the | ||
| # browser hasn't subscribed to the SessionsChannel yet, so an immediate | ||
| # generation would broadcast the streaming container before the socket is | ||
| # listening and the reply would never render live (SEC-506 Bug 4). Instead we | ||
| # render `show` in the reply_pending? state and let the session Stimulus | ||
| # controller POST to RepliesController once it has connected. | ||
| def create | ||
| return head :unprocessable_entity if params[:prompt].blank? | ||
|
|
||
| prompt = current_user.prompts.for(@type).find(params[:prompt_id]) | ||
|
|
||
| @session = Session.new( | ||
| agent: Agents::Roslin.instance, | ||
| record: @record, | ||
| title: prompt.title, | ||
| user: current_user | ||
| ) | ||
| @session.messages.build(content: params[:prompt], user: current_user) | ||
|
|
||
| if @session.save | ||
| publish_event('echo_session.created', session: { id: @session.id }) | ||
| render :show | ||
| else | ||
| head :unprocessable_entity | ||
| end | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def record_params | ||
| params.permit(:id, :prompt, :prompt_id, :project_id, :record, :type) | ||
| end | ||
|
|
||
| def set_record | ||
| @record = current_project.send(@type.to_s.pluralize).find(record_params[:record]) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is overwritten on L67 |
||
| end | ||
|
|
||
| def set_session | ||
| @session = Session.find(record_params[:id]) | ||
| @record = scoped_record(@session) | ||
| end | ||
|
|
||
| # Whitelists the record type against Prompt::SCOPES before it reaches a | ||
| # dynamic `current_project.send(@type.pluralize)` dispatch. An unknown type | ||
| # is an out-of-scope request, not a 500 — raise the same RecordNotFound the | ||
| # scoped lookups do. | ||
| def set_type | ||
| @type = record_params[:type]&.to_sym | ||
| raise ActiveRecord::RecordNotFound unless Prompt::SCOPES.include?(@type) | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| <%# locals: (messages:) -%> | ||
| <%# The live transcript container. ReplyJob and Message#broadcast_created append %> | ||
| <%# new turns into #echo-messages, so its id must stay stable across renders. %> | ||
| <div id="echo-messages" data-behavior="echo-messages"> | ||
| <%= render partial: 'dradis/plugins/echo/projects/sessions/messages/message', | ||
| collection: messages, as: :message %> | ||
| </div> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| <% if Dradis::Plugins::Echo::Agents::Roslin.enabled? %> | ||
| <% if @prompts.any? %> | ||
| <% if @sessions.any? %> | ||
| <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), | ||
| id: dom_id(session), | ||
| class: 'list-group-item list-group-item-action', | ||
| data: { turbo_frame: dom_id(@record, :echo) } %> | ||
| <% end %> | ||
| </div> | ||
| <% end %> | ||
|
|
||
| <p> | ||
| Interact with <i class="fa-solid fa-robot me-1"></i>Roslin by selecting a | ||
| <%= link_to 'saved prompt', echo.prompts_path, data: { turbo_frame: '_top' } %> to start a session for this Issue. | ||
| </p> | ||
| <p> | ||
| You can edit the prompt before sending it, any changes are one-off and | ||
| won't affect the saved prompt template. | ||
| </p> | ||
| <% else %> | ||
| <%= render 'shared/empty_state', | ||
| actions_partial: 'dradis/plugins/echo/prompts/empty_state_actions', | ||
| docs_link: 'https://dradis.com/support/guides/echo/prompts.html', | ||
| name: 'prompt', | ||
| text: 'Create reusable prompts to standardize how your team analyzes findings, writes reports, summarizes vulnerabilities, and more. Save time by building a library of prompts that anyone on your team can use for consistent results.' | ||
| %> | ||
| <% end %> | ||
| <% else %> | ||
| <div class="alert alert-warning"> | ||
| <% if !current_user.respond_to?(:role?) || current_user.role?(:admin) %> | ||
| The Roslin agent is not enabled. | ||
| <%= link_to 'Enable it', echo.agents_path, data: { turbo_frame: '_top' } %> | ||
| to allow LLM interactions for AI-assisted writing. | ||
| <% else %> | ||
| <i class="fa-solid fa-robot me-1"></i>Roslin is not enabled. Ask an | ||
| admin to enable it to allow LLM interactions. | ||
| <% end %> | ||
| </div> | ||
| <p> | ||
| Think of Roslin as an editor or writing companion. It allows you to interact | ||
| with different LLM providers to help enhance your findings' readability | ||
| or adapt them to your audience. | ||
| </p> | ||
| <% end %> |
Uh oh!
There was an error while loading. Please reload this page.