Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
carries, so an exported run imported into another install keeps its place
in the run history instead of reading as just run. A report naming no
finish time is dated on arrival, and a malformed one is refused.
- **GitHub App webhooks** (`actionagent`). `POST <mount>/api/github_app/webhooks`
receives the App's deliveries, verified with `X-Hub-Signature-256` under
`ActionAgent.github_app_webhook_secret` (falling back to
`GITHUB_APP_WEBHOOK_SECRET`; unset, the endpoint answers 404), and applies
them in `ActionAgent::GithubWebhookJob`: an uninstall marks the linked
installation removed, suspend and unsuspend set and clear `suspended_at`,
accepted permissions are recorded, a repository removed from the
installation leaves the owner's selection, and a revoked App authorization
deletes the user's stored connection when connections authorize through the
App. Each `X-GitHub-Delivery` is recorded for seven days, so a redelivery
is a no-op. Ships a new migration,
`create_active_agent_github_webhook_deliveries`; run
`rails generate action_agent:install` and `rails db:migrate` to add it.

### Fixed

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# frozen_string_literal: true

require "openssl"

module ActionAgent
module Api
# POST <mount>/api/github_app/webhooks: the GitHub App's webhook
# deliveries (installation, installation_repositories,
# github_app_authorization), applied by GithubWebhookJob.
#
# GitHub's signature authenticates a delivery on its own, so this is not a
# dashboard controller and sees no session, no forgery protection and none
# of the host's controller concerns. A delivery is accepted only when its
# X-Hub-Signature-256 is the HMAC-SHA256 of the raw body under
# ActionAgent.github_app_webhook_secret; with no secret configured there
# is no endpoint (404).
#
# The answer is 202 as soon as a delivery is recorded and enqueued, or
# recognized as one already recorded (GithubWebhookDelivery), or found to
# be an event the dashboard does not handle. Only a failure to enqueue
# answers otherwise, and leaves the delivery unrecorded so a redelivery
# of it is processed.
#
# The body may be JSON, or a form whose +payload+ field holds the JSON,
# as GitHub sends it when the App's content type is
# application/x-www-form-urlencoded. The signature covers the raw body
# either way.
class GithubAppWebhooksController < ActionController::API
wrap_parameters false

SIGNATURE_HEADER = "X-Hub-Signature-256"
EVENT_HEADER = "X-GitHub-Event"
DELIVERY_HEADER = "X-GitHub-Delivery"

def create
secret = ActionAgent.github_app_webhook_secret
return head(:not_found) if secret.blank?

body = request.raw_post.to_s
unless signed?(body, secret)
return render(json: { error: "Invalid or missing #{SIGNATURE_HEADER}" }, status: :unauthorized)
end

event = request.headers[EVENT_HEADER].to_s
return accepted("ignored") unless event.in?(GithubWebhookJob::EVENTS)

delivery_id = request.headers[DELIVERY_HEADER].to_s
unless delivery_id.match?(/\A[A-Za-z0-9-]{1,64}\z/)
return render(json: { error: "#{DELIVERY_HEADER} is required" }, status: :bad_request)
end

payload = parse(body)
return render(json: { error: "The delivery's body is not a JSON object" }, status: :bad_request) unless payload

job_payload = GithubWebhookJob.payload_for(event, payload)
recorded = GithubWebhookDelivery.record!(
delivery_id: delivery_id, event: event,
event_action: job_payload["action"].presence&.first(64),
installation_id: job_payload.dig("installation", "id").presence&.to_i
)
return accepted("duplicate") unless recorded

begin
GithubWebhookJob.perform_later(event, job_payload)
rescue StandardError
# Not left recorded, so a redelivery of it is processed.
GithubWebhookDelivery.forget!(delivery_id)
raise
end

accepted("queued")
end

private

# Constant time, whatever the header holds.
def signed?(body, secret)
given = request.headers[SIGNATURE_HEADER]
return false unless given.is_a?(String) && given.start_with?("sha256=")

expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
ActiveSupport::SecurityUtils.secure_compare(expected, given)
end

def parse(body)
body = Rack::Utils.parse_query(body)["payload"].to_s if request.media_type == "application/x-www-form-urlencoded"
data = JSON.parse(body)
data if data.is_a?(Hash)
rescue JSON::ParserError
nil
end

def accepted(status)
render json: { status: status }, status: :accepted
end
end
end
end
102 changes: 102 additions & 0 deletions actionagent/app/jobs/action_agent/github_webhook_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# frozen_string_literal: true

module ActionAgent
# Applies a GitHub App webhook delivery that Api::GithubAppWebhooksController
# verified and recorded, so Settings -> Integrations learns of a change on
# GitHub when it happens rather than when a token mint is next refused.
#
# +payload+ is the slice of the delivery GithubWebhookJob.payload_for keeps.
# A delivery about an installation no owner linked changes nothing, and so
# does an event or action not handled here.
#
# Each handler sets state from the payload rather than toggling it, so
# running one twice leaves the same result; the delivery record is what
# keeps an old delivery from being replayed over a newer one.
class GithubWebhookJob < ApplicationJob
queue_as :default

EVENTS = %w[installation installation_repositories github_app_authorization].freeze

# What the job needs from a delivery's +payload+, so the job's arguments
# carry neither the sender's profile nor every repository's details.
def self.payload_for(event, payload)
installation = payload["installation"].is_a?(Hash) ? payload["installation"] : {}
repositories = ->(key) { Array(payload[key]).filter_map { |repo| repo.slice("id", "full_name") if repo.is_a?(Hash) } }
sender = payload["sender"].is_a?(Hash) ? payload["sender"] : {}

{
"action" => payload["action"].to_s,
"installation" => installation.slice("id", "permissions", "repository_selection", "suspended_at"),
"repositories_added" => event == "installation_repositories" ? repositories.call("repositories_added") : [],
"repositories_removed" => event == "installation_repositories" ? repositories.call("repositories_removed") : [],
"sender_id" => sender["id"]
}
end

def perform(event, payload)
case event
when "installation" then installation(payload)
when "installation_repositories" then installation_repositories(payload)
when "github_app_authorization" then authorization(payload)
end
end

private

def installation(payload)
record = linked_installation(payload) or return
reported = payload["installation"]

case payload["action"]
when "deleted"
record.update!(removed_at: Time.current) if record.removed_at.nil?
when "suspend"
record.update!(suspended_at: parse_time(reported["suspended_at"]) || Time.current)
when "unsuspend"
record.update!(suspended_at: nil)
when "new_permissions_accepted"
record.update!(permissions: reported["permissions"]) if reported["permissions"].is_a?(Hash)
end
end

# The repository picker lists what the installation reaches from GitHub
# each time it opens, so an added repository needs nothing stored; a
# removed one leaves the owner's selection, which is what a checkout
# trusts.
def installation_repositories(payload)
record = linked_installation(payload) or return
return unless payload["action"].in?(%w[added removed])

removed = payload["repositories_removed"].map { |repo| repo["id"].to_i }.to_set
attributes = { repositories: record.repositories.reject { |repo| removed.include?(repo["id"].to_i) } }
selection = payload.dig("installation", "repository_selection")
attributes[:repository_selection] = selection if selection.present?
record.update!(attributes)
end

# GitHub revoked the user's authorization of the App. The App's own user
# tokens are never stored (see Api::GithubInstallationsController), so
# this reaches a stored GithubConnection only when the OAuth connection
# flow authorizes through this same App (github_client_id is the App's
# client id). A connection made through a separate OAuth App is a grant
# this revocation does not touch.
def authorization(payload)
return unless payload["action"] == "revoked"
return if payload["sender_id"].blank?
return unless ActionAgent.github_client_id.present? && ActionAgent.github_client_id == ActionAgent.github_app_client_id

GithubConnection.where(github_user_id: payload["sender_id"].to_i).destroy_all
end

def linked_installation(payload)
id = payload.dig("installation", "id")
GithubInstallation.find_by(installation_id: id.to_i) if id.present?
end

def parse_time(value)
Time.iso8601(value) if value.is_a?(String)
rescue ArgumentError
nil
end
end
end
3 changes: 2 additions & 1 deletion actionagent/app/models/action_agent/github_installation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ def repository(full_name)

# Whether GitHub will still mint tokens for this installation, as far as
# the dashboard last heard: false once a mint found it removed or
# suspended, until a later mint succeeds or it is linked again.
# suspended, or a webhook delivery reported it (GithubWebhookJob), until
# a later mint succeeds, an unsuspend is delivered, or it is linked again.
def usable?
removed_at.nil? && suspended_at.nil?
end
Expand Down
45 changes: 45 additions & 0 deletions actionagent/app/models/action_agent/github_webhook_delivery.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

module ActionAgent
# A GitHub App webhook delivery the dashboard accepted, by its
# X-GitHub-Delivery id. Api::GithubAppWebhooksController records one before
# it enqueues the delivery's GithubWebhookJob, so a redelivery of the same
# id (from GitHub's retry or its Redeliver button) finds it and is a no-op:
# a replayed +suspend+ cannot undo a later +unsuspend+.
#
# A row instead of Rails.cache: the unique index makes the check and the
# record one atomic insert across every web process, whatever cache store
# the host runs. Rows hold no payload and are pruned after RETENTION, a
# little longer than GitHub keeps a delivery redeliverable.
#
# Not owned: a delivery is about an installation, whichever owner (if any)
# linked it.
class GithubWebhookDelivery < ApplicationRecord
RETENTION = 7.days

validates :delivery_id, :event, presence: true

# Records the delivery, and answers whether it is the first time this id
# was seen. Rows older than RETENTION are pruned on the way.
#
# @return [Boolean] false for a redelivery
def self.record!(delivery_id:, event:, event_action: nil, installation_id: nil)
where(created_at: ...RETENTION.ago).delete_all

# A savepoint, so a duplicate inside a caller's transaction does not
# abort it on PostgreSQL.
transaction(requires_new: true) do
create!(delivery_id: delivery_id, event: event, event_action: event_action, installation_id: installation_id)
end
true
rescue ActiveRecord::RecordNotUnique
false
end

# Forgets the delivery, so GitHub's redelivery of it is processed. For a
# delivery that was recorded but could not be enqueued.
def self.forget!(delivery_id)
where(delivery_id: delivery_id).delete_all
end
end
end
5 changes: 5 additions & 0 deletions actionagent/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,11 @@
end
end

# The GitHub App's webhook deliveries. Authenticated by GitHub's
# signature (github_app_webhook_secret), not a session; the App's
# Webhook URL is <mount>/api/github_app/webhooks.
post "github_app/webhooks", to: "github_app_webhooks#create", as: :github_app_webhooks

# Creating the GitHub App from a manifest on a self-hosted dashboard.
resource :github_app_manifest, only: [ :create ] do
get :callback
Expand Down
15 changes: 15 additions & 0 deletions actionagent/lib/action_agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,16 @@ def claude_code_auth=(value)
attr_writer :github_app_id, :github_app_private_key, :github_app_slug,
:github_app_client_id, :github_app_client_secret

# The secret GitHub signs the App's webhook deliveries with, which
# POST <mount>/api/github_app/webhooks verifies. Falls back to
# GITHUB_APP_WEBHOOK_SECRET. Not one of the settings
# github_app_configured? needs: without it the endpoint answers 404 and
# the dashboard learns of installation changes only when a token mint is
# refused. Set the App's Webhook URL to <mount>/api/github_app/webhooks
# and subscribe it to Installation and Installation repositories.
# @return [String, nil]
attr_writer :github_app_webhook_secret

# MCP servers the host app itself serves or connects, appended to the
# built-in catalog (MCPCatalog) so the MCP Services view lists them and
# telemetry traffic attributes to them. Each entry is a hash shaped like
Expand Down Expand Up @@ -971,6 +981,10 @@ def github_app_client_secret
@github_app_client_secret.presence || ENV["GITHUB_APP_CLIENT_SECRET"].presence
end

def github_app_webhook_secret
@github_app_webhook_secret.presence || ENV["GITHUB_APP_WEBHOOK_SECRET"].presence
end

# Whether the GitHub App installation flow can run on this install: every
# github_app_* setting is present.
# @return [Boolean]
Expand Down Expand Up @@ -1334,6 +1348,7 @@ def reset!
@github_app_slug = nil
@github_app_client_id = nil
@github_app_client_secret = nil
@github_app_webhook_secret = nil
@trace_retention = nil
@trace_owner_resolver = nil
@usage_recorder = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,12 @@ ActionAgent.configure do |config|
# config.github_app_client_secret = Rails.application.credentials.dig(:github_app, :client_secret)
# config.github_app_private_key = Rails.application.credentials.dig(:github_app, :private_key)
#
# With the App's webhook pointed at <mount>/api/github_app/webhooks, the
# secret its deliveries are signed with (GITHUB_APP_WEBHOOK_SECRET when
# unset). Settings then learns of an uninstall or suspension when it happens.
#
# config.github_app_webhook_secret = Rails.application.credentials.dig(:github_app, :webhook_secret)
#
# Or an OAuth App, connected per owner with the callback URL
# <mount>/api/github_connection/callback (GITHUB_CLIENT_ID and
# GITHUB_CLIENT_SECRET when unset).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

# The GitHub App webhook deliveries the dashboard accepted, kept briefly so a
# redelivery of the same X-GitHub-Delivery is a no-op (see
# ActionAgent::GithubWebhookDelivery). Nothing here is a credential or a
# payload.
class CreateActiveAgentGithubWebhookDeliveries < ActiveRecord::Migration<%= migration_version %>
def change
prefix = ActionAgent.table_name_prefix

create_table "#{prefix}github_webhook_deliveries" do |t|
# GitHub's X-GitHub-Delivery, a GUID. Unique: a second insert of the
# same delivery is how a redelivery is recognized.
t.string :delivery_id, null: false, limit: 64
t.string :event, null: false, limit: 64
t.string :event_action, limit: 64
t.bigint :installation_id
t.datetime :created_at, null: false
t.index :delivery_id, unique: true, name: "index_#{prefix}webhook_deliveries_on_delivery"
t.index :created_at, name: "index_#{prefix}webhook_deliveries_on_created_at"
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"action": "revoked",
"sender": {
"login": "octocat",
"id": 9001,
"type": "User",
"site_admin": false,
"html_url": "https://github.com/octocat"
}
}
Loading
Loading