From ff2c348eba0ab1cfff6149ec2430141646742302 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Wed, 11 Mar 2026 10:33:04 -0400 Subject: [PATCH] Add config.api_authenticate hook for custom API auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows host apps to replace Bearer token authentication with their own auth mechanism (e.g., Trogdor mTLS at Square). The hook follows the same pattern as config.authenticate — receives a request, returns user attrs with :external_id, and auto-provisions users. When api_authenticate is set, the API controllers use it instead of Bearer tokens. When unset, Bearer token auth remains the default. Introduces api_actor_id and api_author_type helpers in BaseController so child controllers don't reference @api_token directly. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019cd872-3abe-7556-8927-9966fe044b6c --- .../coplan/api/v1/base_controller.rb | 49 ++++++++++++++++--- .../coplan/api/v1/comments_controller.rb | 8 +-- .../coplan/api/v1/leases_controller.rb | 2 +- .../coplan/api/v1/operations_controller.rb | 6 +-- .../coplan/api/v1/sessions_controller.rb | 4 +- engine/lib/coplan/configuration.rb | 2 +- 6 files changed, 54 insertions(+), 17 deletions(-) diff --git a/engine/app/controllers/coplan/api/v1/base_controller.rb b/engine/app/controllers/coplan/api/v1/base_controller.rb index 7b2c3c1..f2ab554 100644 --- a/engine/app/controllers/coplan/api/v1/base_controller.rb +++ b/engine/app/controllers/coplan/api/v1/base_controller.rb @@ -2,20 +2,57 @@ module CoPlan module Api module V1 class BaseController < ActionController::API - before_action :authenticate_api_token! + before_action :authenticate_api! private - def authenticate_api_token! + def authenticate_api! token = request.headers["Authorization"]&.delete_prefix("Bearer ") - @api_token = CoPlan::ApiToken.authenticate(token) - unless @api_token - render json: { error: "Invalid or expired API token" }, status: :unauthorized + if token.present? + authenticate_via_token!(token) + return if @api_token + end + + if CoPlan.configuration.api_authenticate + attrs = CoPlan.configuration.api_authenticate.call(request) + if attrs && attrs[:external_id].present? + provision_user_from_hook!(attrs) + return + end + end + + render json: { error: "Unauthorized" }, status: :unauthorized + end + + def provision_user_from_hook!(attrs) + external_id = attrs[:external_id].to_s + @current_api_user = CoPlan::User.find_or_initialize_by(external_id: external_id) + @current_api_user.assign_attributes(attrs.slice(:name, :admin, :metadata).compact) + if @current_api_user.new_record? || @current_api_user.changed? + @current_api_user.save! end + rescue ActiveRecord::RecordNotUnique + @current_api_user = CoPlan::User.find_by!(external_id: external_id) + end + + def authenticate_via_token!(token) + @api_token = CoPlan::ApiToken.authenticate(token) end def current_user - @api_token&.user + @current_api_user || @api_token&.user + end + + # Unique identifier for the API caller — used as actor_id, holder_id, author_id. + # With token auth this is the token's ID; with hook auth it's the user's ID. + def api_actor_id + @api_token&.id || @current_api_user&.id + end + + # The type of actor making the API call. + # Token auth → "local_agent"; hook auth → "human". + def api_author_type + @api_token ? ApiToken::HOLDER_TYPE : "human" end def set_plan diff --git a/engine/app/controllers/coplan/api/v1/comments_controller.rb b/engine/app/controllers/coplan/api/v1/comments_controller.rb index abc4398..23b01a7 100644 --- a/engine/app/controllers/coplan/api/v1/comments_controller.rb +++ b/engine/app/controllers/coplan/api/v1/comments_controller.rb @@ -18,8 +18,8 @@ def create thread.save! comment = thread.comments.create!( - author_type: ApiToken::HOLDER_TYPE, - author_id: @api_token.id, + author_type: api_author_type, + author_id: api_actor_id, body_markdown: params[:body_markdown], agent_name: params[:agent_name] ) @@ -83,8 +83,8 @@ def reply end comment = thread.comments.create!( - author_type: ApiToken::HOLDER_TYPE, - author_id: @api_token.id, + author_type: api_author_type, + author_id: api_actor_id, body_markdown: params[:body_markdown], agent_name: params[:agent_name] ) diff --git a/engine/app/controllers/coplan/api/v1/leases_controller.rb b/engine/app/controllers/coplan/api/v1/leases_controller.rb index e62a9b2..7a026da 100644 --- a/engine/app/controllers/coplan/api/v1/leases_controller.rb +++ b/engine/app/controllers/coplan/api/v1/leases_controller.rb @@ -11,7 +11,7 @@ def create lease = EditLease.acquire!( plan: @plan, holder_type: ApiToken::HOLDER_TYPE, - holder_id: @api_token.id, + holder_id: api_actor_id, lease_token: lease_token ) diff --git a/engine/app/controllers/coplan/api/v1/operations_controller.rb b/engine/app/controllers/coplan/api/v1/operations_controller.rb index c8606f9..b6b37a4 100644 --- a/engine/app/controllers/coplan/api/v1/operations_controller.rb +++ b/engine/app/controllers/coplan/api/v1/operations_controller.rb @@ -33,7 +33,7 @@ def create private def apply_with_session(operations, base_revision) - session = @plan.edit_sessions.find_by(id: params[:session_id], actor_id: @api_token.id) + session = @plan.edit_sessions.find_by(id: params[:session_id], actor_id: api_actor_id) unless session&.active? render json: { error: "Edit session not found, expired, or not open" }, status: :not_found return @@ -210,8 +210,8 @@ def commit_version(current_content, result) plan: @plan, revision: new_revision, content_markdown: result[:content], - actor_type: ApiToken::HOLDER_TYPE, - actor_id: @api_token.id, + actor_type: api_author_type, + actor_id: api_actor_id, change_summary: params[:change_summary], diff_unified: diff.presence, operations_json: result[:applied], diff --git a/engine/app/controllers/coplan/api/v1/sessions_controller.rb b/engine/app/controllers/coplan/api/v1/sessions_controller.rb index cf6d7c9..37bba31 100644 --- a/engine/app/controllers/coplan/api/v1/sessions_controller.rb +++ b/engine/app/controllers/coplan/api/v1/sessions_controller.rb @@ -19,7 +19,7 @@ def create session = EditSession.create!( plan: @plan, actor_type: actor_type, - actor_id: @api_token.id, + actor_id: api_actor_id, base_revision: @plan.current_revision, expires_at: ttl.from_now ) @@ -69,7 +69,7 @@ def commit private def set_session - @session = @plan.edit_sessions.find_by(id: params[:id], actor_id: @api_token.id) + @session = @plan.edit_sessions.find_by(id: params[:id], actor_id: api_actor_id) unless @session render json: { error: "Edit session not found" }, status: :not_found end diff --git a/engine/lib/coplan/configuration.rb b/engine/lib/coplan/configuration.rb index 0600280..97bcfcc 100644 --- a/engine/lib/coplan/configuration.rb +++ b/engine/lib/coplan/configuration.rb @@ -1,6 +1,6 @@ module CoPlan class Configuration - attr_accessor :authenticate, :sign_in_path + attr_accessor :authenticate, :api_authenticate, :sign_in_path attr_accessor :ai_base_url, :ai_api_key, :ai_model attr_accessor :error_reporter attr_accessor :notification_handler