Merge pull request #1232 from basecamp/flavorjones/local-authentication-step2

Standalone authentication: step 2, remove SignalId
This commit is contained in:
Mike Dalessio
2025-10-03 14:20:00 -04:00
committed by GitHub
31 changed files with 68 additions and 1269 deletions
-76
View File
@@ -1,19 +1,3 @@
GIT
remote: https://github.com/basecamp/file_repository
revision: 8c8d1594f3ec7e8c0938e6a7f7def521692344e4
ref: 8c8d1594f3ec7e8c0938e6a7f7def521692344e4
specs:
file_repository (1.4.6)
GIT
remote: https://github.com/basecamp/portfolio
revision: 86411a53d19a42ce44705a50ebe7c22828597991
ref: 86411a53d19a42ce44705a50ebe7c22828597991
specs:
portfolio (5.2.0)
activesupport
file_repository (>= 1.4.4)
GIT
remote: https://github.com/basecamp/queenbee-plugin
revision: eb01c697de1ad028afc65cc7d9b5345a7a8e849f
@@ -24,16 +8,6 @@ GIT
builder
rexml
GIT
remote: https://github.com/basecamp/queuety
revision: bf7e2a552fb674533d3e092f06dbf9b5d1ebc753
ref: bf7e2a552fb674533d3e092f06dbf9b5d1ebc753
branch: rails4
specs:
queuety (4.0.2)
activerecord
service_concurrency_prevention
GIT
remote: https://github.com/basecamp/rails-structured-logging
revision: 76960cb5c15fc2b6b5f7542e05d7dcc031cef9e6
@@ -42,31 +16,6 @@ GIT
json
rails (>= 6.0.0)
GIT
remote: https://github.com/basecamp/service_concurrency_prevention
revision: 320da99431718615ed86f8723e8b9d3259c602a3
ref: 320da99431718615ed86f8723e8b9d3259c602a3
specs:
service_concurrency_prevention (1.1.1)
activesupport
GIT
remote: https://github.com/basecamp/signal_id
revision: d6c2a5ceed7bb57bf7dcdebdb439e7cd526fca09
ref: d6c2a5ceed7bb57bf7dcdebdb439e7cd526fca09
branch: rails4
specs:
signal_id (4.3.2)
bcrypt-ruby (>= 3.0.0)
google-id-token (>= 1.4.0)
mocha
multi_json
portfolio (>= 4.0)
pwned (~> 2.0)
queuety (>= 3.0)
rails (>= 4.0.2)
rails-controller-testing
GIT
remote: https://github.com/crmne/ruby_llm.git
revision: 32611afc114c5f40696dd42f8a1eaee6b6b81330
@@ -81,14 +30,6 @@ GIT
marcel (~> 1.0)
zeitwerk (~> 2)
GIT
remote: https://github.com/jeremy/mysql2.git
revision: b5766f5d296745d6aed014c8c0b7d82ef149ade1
ref: b5766f5d296745d6aed014c8c0b7d82ef149ade1
branch: force_latin1_to_utf8
specs:
mysql2 (0.5.4.latin1utf8)
GIT
remote: https://github.com/rails/rails.git
revision: d8795bd762ab2716b6e7fd07a403566b28159cdc
@@ -206,7 +147,6 @@ PATH
queenbee
rails (>= 8.1.0.beta1)
rails_structured_logging
signal_id
GEM
remote: https://rubygems.org/
@@ -251,8 +191,6 @@ GEM
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0)
bcrypt (3.1.20)
bcrypt-ruby (3.1.5)
bcrypt (>= 3.1.3)
bcrypt_pbkdf (1.1.1)
benchmark (0.4.1)
bigdecimal (3.2.3)
@@ -317,8 +255,6 @@ GEM
addressable (>= 2.5.0)
globalid (1.2.1)
activesupport (>= 6.1)
google-id-token (1.4.2)
jwt (>= 1)
hashdiff (1.2.1)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
@@ -391,7 +327,6 @@ GEM
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
multi_json (1.17.0)
multipart-post (2.4.1)
net-http (0.6.0)
uri
@@ -464,7 +399,6 @@ GEM
public_suffix (6.0.2)
puma (7.0.3)
nio4r (~> 2.0)
pwned (2.4.1)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.1)
@@ -475,10 +409,6 @@ GEM
rack (>= 1.3)
rackup (2.2.1)
rack (>= 3)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -670,7 +600,6 @@ DEPENDENCIES
bundler-audit
capybara
debug
file_repository (~> 1.4.5)!
fizzy-saas!
geared_pagination (~> 1.2)
image_processing (~> 1.14)
@@ -681,15 +610,12 @@ DEPENDENCIES
lexxy
mission_control-jobs
mocha
mysql2!
net-http-persistent
platform_agent
portfolio (>= 4.6)!
prometheus-client-mmap (~> 1.1)
propshaft
puma (>= 5.0)
queenbee!
queuety!
rails!
rails_structured_logging!
redcarpet
@@ -700,8 +626,6 @@ DEPENDENCIES
selenium-webdriver
sentry-rails
sentry-ruby
service_concurrency_prevention!
signal_id!
solid_cable (>= 3.0)
solid_cache (~> 1.0)
solid_queue (~> 1.1)
+15
View File
@@ -3,6 +3,21 @@ class Account < ApplicationRecord
has_many_attached :uploads
class << self
def create_with_admin_user(tenant_id:, account_name:, owner_name:, owner_email:)
account = create!(tenant_id:, name: account_name)
User.create!(
name: owner_name,
email_address: owner_email,
role: "admin",
password: SecureRandom.hex(16)
)
account
end
end
def slug
"/#{tenant}"
end
+1 -3
View File
@@ -15,9 +15,7 @@ load File.expand_path("../gems/fizzy-saas/bin/setup", __dir__) unless Bootstrap.
puts "\n== Preparing database =="
if ARGV.include?("--reset")
system! "bin/rails db:drop db:create db:schema:load db:prepare"
system! "bin/rails db:fixtures:load" # load signal id fixtures before running fizzy seeds
system! "bin/rails db:seed"
system! "bin/rails db:drop db:create db:schema:load db:prepare db:seed"
else
system! "bin/rails db:prepare"
end
-1
View File
@@ -11,7 +11,6 @@ CI.run do
step "Tests: Rails: SaaS config", "bin/rails test"
step "Tests: Rails: OSS config", "OSS_CONFIG=1 bin/rails test"
step "Tests: 37id", "bin/rails 37id:test:units"
step "Tests: System", "bin/rails test:system"
if success?
@@ -0,0 +1,5 @@
class RemoveExternalUserIdFromUsers < ActiveRecord::Migration[8.1]
def change
remove_column :users, :external_user_id, :integer
end
end
Generated
+1 -3
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_09_24_124737) do
ActiveRecord::Schema[8.1].define(version: 2025_10_01_181713) do
create_table "accesses", force: :cascade do |t|
t.datetime "accessed_at"
t.integer "collection_id", null: false
@@ -451,13 +451,11 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_24_124737) do
t.boolean "active", default: true, null: false
t.datetime "created_at", null: false
t.string "email_address"
t.integer "external_user_id"
t.string "name", null: false
t.string "password_digest"
t.string "role", default: "member", null: false
t.datetime "updated_at", null: false
t.index ["email_address"], name: "index_users_on_email_address", unique: true
t.index ["external_user_id"], name: "index_users_on_external_user_id", unique: true
t.index ["role"], name: "index_users_on_role"
end
+1 -27
View File
@@ -1290,16 +1290,6 @@ columns:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: external_user_id
cast_type: *3
sql_type_metadata: *4
'null': true
default:
default_function:
collation:
comment:
- *6
- *10
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
@@ -3098,22 +3088,6 @@ indexes:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: users
name: index_users_on_external_user_id
unique: true
columns:
- external_user_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: users
name: index_users_on_role
@@ -3248,4 +3222,4 @@ indexes:
comment:
valid: true
workflows: []
version: 20250924124737
version: 20251001181713
+7 -15
View File
@@ -13,26 +13,18 @@ end
def create_tenant(signal_account_name, bare: false)
if bare
tenant_id = Digest::SHA256.hexdigest(signal_account_name)[0..8].to_i(16)
elsif Rails.application.config.x.oss_config
tenant_id = ActiveRecord::FixtureSet.identify signal_account_name
else
signal_account = SignalId::Account.find_by_product_and_name!("fizzy", signal_account_name)
tenant_id = signal_account.queenbee_id
tenant_id = ActiveRecord::FixtureSet.identify signal_account_name
end
ApplicationRecord.destroy_tenant tenant_id
ApplicationRecord.create_tenant(tenant_id) do
account = if bare || Rails.application.config.x.oss_config
Account.create(name: signal_account_name, tenant_id: tenant_id).tap do
User.create!(
name: "David Heinemeier Hansson",
email_address: "david@37signals.com",
password: "secret123456"
)
end
else
Account.create_with_admin_user(tenant_id: tenant_id)
end
account = Account.create_with_admin_user(
tenant_id: tenant_id,
account_name: signal_account_name,
owner_name: "David Heinemeier Hansson",
owner_email: "david@37signals.com",
)
account.setup_basic_template
end
-6
View File
@@ -2,11 +2,5 @@ source "https://rubygems.org"
git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" }
# 37id and Queenbee integration
gem "signal_id", bc: "signal_id", branch: "rails4", ref: "d6c2a5ceed7bb57bf7dcdebdb439e7cd526fca09"
gem "mysql2", github: "jeremy/mysql2", branch: "force_latin1_to_utf8", ref: "b5766f5d296745d6aed014c8c0b7d82ef149ade1" # needed by signal_id
gem "queuety", bc: "queuety", branch: "rails4", ref: "bf7e2a552fb674533d3e092f06dbf9b5d1ebc753" # needed by signal_id
gem "service_concurrency_prevention", bc: "service_concurrency_prevention", ref: "320da99431718615ed86f8723e8b9d3259c602a3" # needed by queuety
gem "portfolio", ">= 4.6", bc: "portfolio", ref: "86411a53d19a42ce44705a50ebe7c22828597991" # needed by signal_id
gem "file_repository", "~> 1.4.5", bc: "file_repository", ref: "8c8d1594f3ec7e8c0938e6a7f7def521692344e4" # needed by portfolio
gem "queenbee", bc: "queenbee-plugin", ref: "eb01c697de1ad028afc65cc7d9b5345a7a8e849f"
gem "activeresource", require: "active_resource" # needed by queenbee
-243
View File
@@ -1,19 +1,3 @@
GIT
remote: https://github.com/basecamp/file_repository
revision: 8c8d1594f3ec7e8c0938e6a7f7def521692344e4
ref: 8c8d1594f3ec7e8c0938e6a7f7def521692344e4
specs:
file_repository (1.4.6)
GIT
remote: https://github.com/basecamp/portfolio
revision: 86411a53d19a42ce44705a50ebe7c22828597991
ref: 86411a53d19a42ce44705a50ebe7c22828597991
specs:
portfolio (5.2.0)
activesupport
file_repository (>= 1.4.4)
GIT
remote: https://github.com/basecamp/queenbee-plugin
revision: eb01c697de1ad028afc65cc7d9b5345a7a8e849f
@@ -24,118 +8,19 @@ GIT
builder
rexml
GIT
remote: https://github.com/basecamp/queuety
revision: bf7e2a552fb674533d3e092f06dbf9b5d1ebc753
ref: bf7e2a552fb674533d3e092f06dbf9b5d1ebc753
branch: rails4
specs:
queuety (4.0.2)
activerecord
service_concurrency_prevention
GIT
remote: https://github.com/basecamp/service_concurrency_prevention
revision: 320da99431718615ed86f8723e8b9d3259c602a3
ref: 320da99431718615ed86f8723e8b9d3259c602a3
specs:
service_concurrency_prevention (1.1.1)
activesupport
GIT
remote: https://github.com/basecamp/signal_id
revision: d6c2a5ceed7bb57bf7dcdebdb439e7cd526fca09
ref: d6c2a5ceed7bb57bf7dcdebdb439e7cd526fca09
branch: rails4
specs:
signal_id (4.3.2)
bcrypt-ruby (>= 3.0.0)
google-id-token (>= 1.4.0)
mocha
multi_json
portfolio (>= 4.0)
pwned (~> 2.0)
queuety (>= 3.0)
rails (>= 4.0.2)
rails-controller-testing
GIT
remote: https://github.com/jeremy/mysql2.git
revision: b5766f5d296745d6aed014c8c0b7d82ef149ade1
ref: b5766f5d296745d6aed014c8c0b7d82ef149ade1
branch: force_latin1_to_utf8
specs:
mysql2 (0.5.4.latin1utf8)
GEM
remote: https://rubygems.org/
specs:
actioncable (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
mail (>= 2.8.0)
actionmailer (8.0.2.1)
actionpack (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activesupport (= 8.0.2.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.0.2.1)
actionview (= 8.0.2.1)
activesupport (= 8.0.2.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.0.2.1)
actionpack (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.0.2.1)
activesupport (= 8.0.2.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.0.2.1)
activesupport (= 8.0.2.1)
globalid (>= 0.3.6)
activemodel (8.0.2.1)
activesupport (= 8.0.2.1)
activemodel-serializers-xml (1.0.3)
activemodel (>= 5.0.0.a)
activesupport (>= 5.0.0.a)
builder (~> 3.1)
activerecord (8.0.2.1)
activemodel (= 8.0.2.1)
activesupport (= 8.0.2.1)
timeout (>= 0.4.0)
activeresource (6.1.4)
activemodel (>= 6.0)
activemodel-serializers-xml (~> 1.0)
activesupport (>= 6.0)
activestorage (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activesupport (= 8.0.2.1)
marcel (~> 1.0)
activesupport (8.0.2.1)
base64
benchmark (>= 0.3)
@@ -150,143 +35,21 @@ GEM
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
base64 (0.3.0)
bcrypt (3.1.20)
bcrypt-ruby (3.1.5)
bcrypt (>= 3.1.3)
benchmark (0.4.1)
bigdecimal (3.2.3)
builder (3.3.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.4)
crass (1.0.6)
date (3.4.1)
drb (2.2.3)
erb (5.0.2)
erubi (1.13.1)
globalid (1.2.1)
activesupport (>= 6.1)
google-id-token (1.4.2)
jwt (>= 1)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
io-console (0.8.1)
irb (1.15.2)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jwt (3.1.2)
base64
logger (1.7.0)
loofah (2.24.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.0.4)
mini_mime (1.1.5)
minitest (5.25.5)
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
multi_json (1.17.0)
net-imap (0.5.10)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.9-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.9-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.9-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.9-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.9-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-musl)
racc (~> 1.4)
pp (0.6.2)
prettyprint
prettyprint (0.2.0)
psych (5.2.6)
date
stringio
pwned (2.4.1)
racc (1.8.1)
rack (3.2.1)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rack (>= 3)
rails (8.0.2.1)
actioncable (= 8.0.2.1)
actionmailbox (= 8.0.2.1)
actionmailer (= 8.0.2.1)
actionpack (= 8.0.2.1)
actiontext (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activemodel (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
bundler (>= 1.15.0)
railties (= 8.0.2.1)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
rake (13.3.0)
rdoc (6.14.2)
erb
psych (>= 4.0.0)
reline (0.6.2)
io-console (~> 0.5)
rexml (3.4.4)
ruby2_keywords (0.0.5)
securerandom (0.4.1)
stringio (3.1.7)
thor (1.4.0)
timeout (0.4.3)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uri (1.0.3)
useragent (0.16.11)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
zeitwerk (2.7.3)
PLATFORMS
aarch64-linux-gnu
@@ -300,13 +63,7 @@ PLATFORMS
DEPENDENCIES
activeresource
file_repository (~> 1.4.5)!
mysql2!
portfolio (>= 4.6)!
queenbee!
queuety!
service_concurrency_prevention!
signal_id!
BUNDLED WITH
2.7.0
@@ -1,30 +0,0 @@
class Signup::AccountsController < Signup::BaseController
before_action :reset_signup_storage
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to saas.new_signup_account_path, alert: "Try again later." }
def new
@signup = Signup.new
end
def create
@signup = Signup.new(signup_params)
if @signup.recognized?
store_signup
redirect_to Launchpad.authentication_url(purpose: "signup", login_hint: @signup.email_address, redirect_uri: saas.signup_session_url), allow_other_host: true
elsif @signup.process
redirect_to_account(@signup.account)
else
render :new, status: :unprocessable_entity
end
end
private
def signup_params
params.require(:signup).permit(*Signup::PERMITTED_KEYS)
end
def store_signup
@signup.to_h.each { |key, value| signup_storage[key.to_s] = value }
end
end
@@ -1,36 +0,0 @@
class Signup::BaseController < ApplicationController
require_untenanted_access
# TODO: Remove this auth before launch.
http_basic_authenticate_with(
name: Rails.env.test? ? "testname" : Rails.application.credentials.account_signup_http_basic_auth.name,
password: Rails.env.test? ? "testpassword" : Rails.application.credentials.account_signup_http_basic_auth.password
)
attr_reader :authenticated_identity
private
def signup_storage
session[:signup] ||= {}
end
def reset_signup_storage
session.delete(:signup)
end
def authenticated_identity=(identity)
@authenticated_identity = identity
signup_storage["identity_id"] = identity.try(:id)
end
def set_authenticated_identity
if identity_id = signup_storage["identity_id"]
@authenticated_identity = SignalId::Identity.find_by(id: identity_id)
end
end
def redirect_to_account(account)
redirect_to account.external_account.owner.remote_login_url(proceed_to: root_path),
allow_other_host: true
end
end
@@ -1,23 +0,0 @@
class Signup::CompletionsController < Signup::BaseController
before_action :set_authenticated_identity, only: :create
def new
end
def create
@signup = Signup.new(signup_params)
@signup.signal_identity = authenticated_identity
if @signup.process
reset_signup_storage
redirect_to_account(@signup.account)
else
render plain: "Could not complete signup. This is probably a bug.", status: :unprocessable_entity, layout: false
end
end
private
def signup_params
signup_storage.slice(*Signup::PERMITTED_KEYS.map(&:to_s))
end
end
@@ -1,9 +0,0 @@
class Signup::SessionsController < Signup::BaseController
def create
if self.authenticated_identity = SignalId::Identity.authenticate(params.permit(:sig))
redirect_to saas.new_signup_completion_url
else
render plain: "Authentication failed. This is probably a bug.", status: :unauthorized
end
end
end
@@ -1,30 +0,0 @@
module Account::SignalAccount
extend ActiveSupport::Concern
prepended do
# the tenant_id column is the SignalId::Account's queenbee_id
belongs_to :external_account, class_name: "SignalId::Account", primary_key: :queenbee_id, foreign_key: :tenant_id, optional: true
end
class_methods do
def find_by_queenbee_id(queenbee_id)
find_by(tenant_id: queenbee_id)
end
def create_with_admin_user(tenant_id:)
new(tenant_id:).tap do |account|
SignalId::Database.on_master do
account.name = account.external_account.name
account.save!
User.create!(
name: account.external_account.owner.name,
email_address: account.external_account.owner.email_address,
role: "admin",
password: SecureRandom.hex(16)
)
end
end
end
end
end
-24
View File
@@ -1,24 +0,0 @@
# lifted from bc3 app/models/launchpad.rb
module Launchpad
extend self
delegate :url, to: "SignalId.launchpad"
def login_url(product: false, account: nil, **params)
url product_account_path("/signin", product: product, account: account), params
end
def logout_url
url "/signout"
end
def authentication_url(**params)
url "/authenticate", params.merge(product: :fizzy)
end
def product_account_path(path = nil, product: false, account: nil)
product_path = "/fizzy" if product || account
account_path = "/#{account.external_account.id}" if account
[ product_path, account_path, path ].compact.join
end
end
-159
View File
@@ -1,159 +0,0 @@
class Signup
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Validations
PERMITTED_KEYS = %i[ full_name email_address password company_name ]
# Input attribute
attr_accessor :company_name
# Input attributes when not using an existing identity
attr_accessor :full_name, :email_address, :password
# Input attribute when using an existing identity, output when creating new identity
attr_accessor :signal_identity
# Output attributes
attr_reader :queenbee_account, :signal_account, :account, :user
validate :validate_new_identity
def initialize(...)
@full_name = nil
@email_address = nil
@password = nil
@company_name = nil
@signal_identity = nil
@queenbee_account = nil
@account = nil
@user = nil
super
end
def process(destroy_existing_tenant: false)
return false unless valid?
create_signal_identity
create_queenbee_account
create_tenant(destroy_existing_tenant:)
true
rescue => error
destroy_tenant
destroy_queenbee_account
destroy_signal_identity
errors.add(:base, "An error occurred during signup: #{error.message}")
false
end
def recognized?
SignalId::Identity.find_by_email_address(email_address).present?
end
def tenant_name
@tenant_name ||= @queenbee_account.id.to_s
end
def to_h
{
full_name: full_name,
email_address: email_address,
company_name: company_name
}.compact_blank
end
private
def create_signal_identity
unless signal_identity.present?
SignalId::Database.on_master do
@signal_identity = build_new_identity.tap(&:save!)
end
end
end
def create_queenbee_account
@queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes)
SignalId::Database.on_master do
@signal_account = SignalId::Account.find_by(queenbee_id: @queenbee_account.id)
end
end
def create_tenant(destroy_existing_tenant: false, &block)
ApplicationRecord.destroy_tenant(tenant_name) if destroy_existing_tenant
ApplicationRecord.create_tenant(tenant_name) do
@account = Account.create_with_admin_user(tenant_id: queenbee_account.id)
@account.setup_basic_template
@user = User.first
end
end
def destroy_tenant
if queenbee_account && ApplicationRecord.tenant_exist?(tenant_name)
ApplicationRecord.destroy_tenant(tenant_name)
@account = nil
@user = nil
end
end
def destroy_queenbee_account
queenbee_account&.cancel
@queenbee_account = nil
end
def destroy_signal_identity
SignalId::Database.on_master do
signal_identity&.destroy
end
@signal_identity = nil
end
def validate_new_identity
unless signal_identity.present?
build_new_identity.tap do |identity|
unless identity.valid?
identity.errors.each { |error| errors.add(error.attribute, error.message) }
end
end
end
end
def build_new_identity
SignalId::Identity.new do |identity|
identity.name = full_name || email_address
identity.email_address = email_address
identity.username = email_address
identity.password = password
end
end
def queenbee_account_attributes
{
skip_remote: true, # Fizzy creates its own local account
product_name: "fizzy",
name: queenbee_account_name,
owner_identity_id: signal_identity.id,
trial: false,
subscription: subscription_attributes,
remote_request: request_attributes
}
end
def subscription_attributes
subscription = FreeV1Subscription
{ name: subscription.to_param, price: subscription.price }
end
def request_attributes
{ remote_address: Current.ip_address, user_agent: Current.user_agent, referrer: Current.referrer }
end
def queenbee_account_name
name = company_name.presence || signal_identity.name
name += " (Beta)" if Rails.env.beta?
name
end
end
@@ -1,3 +0,0 @@
<%= auto_submit_form_with(method: :put, url: saas.session_launchpad_path) do |form| %>
<%= form.hidden_field(:sig, value: @sig) %>
<% end %>
@@ -1,58 +0,0 @@
<% @page_title = "Sign up for Fizzy" %>
<div class="panel shadow center margin-block-double <%= "shake" if flash[:alert] %>" style="--panel-size: 65ch;">
<h1 class="txt-xx-large margin-block-end-double">Fizzy</h1>
<h2 class="txt-large margin-block-end-double">Create your account</h2>
<%= form_with model: @signup, url: saas.signup_accounts_path, scope: "signup", class: "flex flex-column gap txt-large", data: { turbo: false } do |form| %>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.text_field :full_name, class: "input", autocomplete: "name", placeholder: "Your name", autofocus: true, required: true %>
<%= icon_tag "person", class: "txt-large" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.email_field :email_address, class: "input", autocomplete: "username", placeholder: "Your email address", required: true %>
<%= icon_tag "email", class: "txt-large" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.text_field :company_name, class: "input", autocomplete: "organization", placeholder: "Your organization's name", required: true %>
<%= icon_tag "building", class: "txt-large" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.password_field :password, class: "input", autocomplete: "new-password", placeholder: "Password (at least 12 characters)", required: true, maxlength: 72, minlength: 12 %>
<%= icon_tag "password", class: "txt-large" %>
</label>
</div>
<% if @signup.errors.any? %>
<div class="alert alert--error">
<ul class="margin-block-none">
<% @signup.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<button type="submit" class="btn btn--reversed center">
<%= icon_tag "arrow-right" %>
<span>Create your account</span>
</button>
<% end %>
<footer class="margin-block-start-double txt-center">
<p class="txt-small txt-muted">
Already have an account?
<%= link_to "Sign in", login_url, class: "decorated" %>
</p>
</footer>
</div>
@@ -1 +0,0 @@
<%= auto_submit_form_with url: saas.signup_completions_path, data: { turbo: false } %>
-7
View File
@@ -1,10 +1,3 @@
Fizzy::Saas::Engine.routes.draw do
namespace :signup do
get "/" => "accounts#new"
resources :accounts, only: %i[ new create ]
get "/session" => "sessions#create" # redirect from Launchpad after mid-signup authentication
resources :completions, only: %i[ new create ]
end
Queenbee.routes(self)
end
-1
View File
@@ -22,7 +22,6 @@ Gem::Specification.new do |spec|
end
spec.add_dependency "rails", ">= 8.1.0.beta1"
spec.add_dependency "signal_id"
spec.add_dependency "queenbee"
spec.add_dependency "rails_structured_logging"
end
-28
View File
@@ -1,13 +1,6 @@
require "signal_id"
module Fizzy
module Saas
class Engine < ::Rails::Engine
# extend application models
config.to_prepare do
Account.prepend Account::SignalAccount
end
# moved from config/initializers/queenbee.rb
Queenbee.host_app = Fizzy
@@ -21,27 +14,6 @@ module Fizzy
::Object.const_set const_name, Subscription.const_get(short_name, false)
end
end
# moved from config/initializers/signal_id.rb
config.before_initialize do
ENV["SIGNAL_ID_SECRET"] = Rails.application.credentials.signal_id_secret
end
config.to_prepare do
SignalId.product = "fizzy"
db_config = SignalId::Database.default_configuration
SignalId::Database.load_configuration db_config
SignalId::Database.enable_rw_splitting!
silence_warnings do
SignalId::Account::Peer = Account
end
end
config.after_initialize do
ActiveRecord.yaml_column_permitted_classes << SignalId::PersonName
end
end
end
end
@@ -1,103 +0,0 @@
require "test_helper"
class Signup::AccountsControllerTest < ActionDispatch::IntegrationTest
test "new under a tenanted URL redirects to the root" do
get saas.new_signup_account_url
assert_redirected_to root_url
end
test "new under an untenanted URL is OK" do
integration_session.default_url_options[:script_name] = "" # no tenant
get saas.new_signup_account_url, headers: auth_headers
assert_response :success
end
test "create with invalid params" do
integration_session.default_url_options[:script_name] = "" # no tenant
post saas.signup_accounts_url,
headers: auth_headers,
params: { signup: { full_name: "Jim", email_address: "jim@example.com", password: "", company_name: "" } }
assert_response :unprocessable_entity
assert_select "div.alert--error", text: /you need to choose a password/i
end
test "create for a new identity" do
integration_session.default_url_options[:script_name] = "" # no tenant
assert_difference -> { SignalId::Identity.count }, +1 do
assert_difference -> { SignalId::Account.count }, +1 do
post saas.signup_accounts_url, headers: auth_headers,
params: {
signup: {
full_name: "Jim",
email_address: "jim@example.com",
password: SecureRandom.hex(12),
company_name: "signup-accounts-controller-test-1"
}
}
end
end
signal_account = SignalId::Account.last
assert_redirected_to(/#{signal_account.login_url}/)
end
test "create for an existing identity" do
integration_session.default_url_options[:script_name] = "" # no tenant
identity = signal_identities(:david)
post saas.signup_accounts_url, headers: auth_headers,
params: { signup: { email_address: identity.email_address, company_name: "signup-accounts-controller-test-2" } }
assert_authentication_requested_for identity
assert_no_difference -> { SignalId::Identity.count } do
assert_difference -> { SignalId::Account.count } do
authenticate_via_launchpad_as(identity)
assert_redirected_to_account
assert_nil session[:signup]
end
end
signal_account = SignalId::Account.last
ApplicationRecord.with_tenant(signal_account.queenbee_id) do
assert_equal Account.last, signal_account.peer
end
end
test "actions require HTTP basic authentication while we're in internal-only mode" do
integration_session.default_url_options[:script_name] = "" # no tenant
get saas.new_signup_account_url
assert_response :unauthorized
end
private
def auth_headers
{ "HTTP_AUTHORIZATION" => ActionController::HttpAuthentication::Basic.encode_credentials("testname", "testpassword") }
end
def assert_authentication_requested_for(identity)
assert_response :redirect
assert_match(/#{Regexp.escape(Launchpad.url("/authenticate", login_hint: identity.email_address))}.*&purpose=signup/, redirect_to_url)
end
def authenticate_via_launchpad_as(identity)
get saas.signup_session_url, headers: auth_headers, params: { sig: identity.perishable_signature }
assert_equal identity.id, session[:signup]["identity_id"]
assert_redirected_to saas.new_signup_completion_url
post saas.signup_completions_url, headers: auth_headers
end
def assert_redirected_to_account(signal_account = SignalId::Account.last)
assert_response :redirect
assert_match(/#{Regexp.escape(signal_account.url("/session/launchpad"))}.*&sig=/, redirect_to_url)
end
end
@@ -1,41 +0,0 @@
require "test_helper"
class Account::SignalAccountTest < ActiveSupport::TestCase
# # TODO(MIKE): Queenbee client API compliance tests
# include Queenbee::Testing::Client
setup do
@account = accounts("37s")
end
test "belongs to a signal_account via a shared queenbee_id" do
assert_not_nil @account.tenant_id
assert_equal @account.tenant_id, Account.new(external_account: @account.external_account).tenant_id
assert_equal @account.external_account, Account.new(tenant_id: @account.tenant_id).external_account
end
test "peering" do
assert_equal @account, @account.external_account.peer
end
test ".create_with_admin_user creates a new local account" do
ApplicationRecord.create_tenant("account-create-with-dependents") do
signal_account = signal_accounts(:honcho_fizzy)
account = Account.create_with_admin_user(tenant_id: signal_account.queenbee_id)
assert_not_nil account
assert account.persisted?
assert_equal 1, Account.count
assert_equal signal_account.queenbee_id, account.tenant_id
assert_equal signal_account.name, account.name
assert_equal account, signal_account.peer
assert_equal 1, User.count
User.first.tap do |user|
assert_equal signal_account.owner.name, user.name
assert_equal signal_account.owner.email_address, user.email_address
assert_equal "admin", user.role
end
end
end
end
-149
View File
@@ -1,149 +0,0 @@
require "test_helper"
class SignupTest < ActiveSupport::TestCase
setup do
@signup = Signup.new(
email_address: "brian@example.com",
full_name: "Brian Wilson",
company_name: "Beach Boys",
password: SecureRandom.hex(16)
)
end
test "#to_h allows persistence of the signup data" do
actual = @signup.to_h
expected = {
email_address: @signup.email_address,
full_name: @signup.full_name,
company_name: @signup.company_name
}
assert_equal expected, actual
end
test "#process creates all the necessary objects for a new identity" do
Account.any_instance.expects(:setup_basic_template).once
assert @signup.process, @signup.errors.full_messages.to_sentence(words_connector: ". ")
assert_empty @signup.errors
assert @signup.signal_identity
assert @signup.signal_identity.persisted?
assert @signup.queenbee_account
assert @signup.queenbee_account.id
assert @signup.signal_account
assert @signup.signal_account.persisted?
assert_equal @signup.signal_identity, @signup.signal_account.owner.identity
assert_equal @signup.company_name, @signup.signal_account.name
assert @signup.account
assert @signup.account.persisted?
assert @signup.user
assert @signup.user.persisted?
assert_equal @signup.queenbee_account.id.to_s, @signup.tenant_name
assert_includes ApplicationRecord.tenants, @signup.tenant_name
end
test "the new account is named with company name if present" do
assert_equal @signup.company_name, @signup.send(:queenbee_account_name)
end
test "in beta, the new account is named distinctly" do
Rails.stubs(:env).returns(stub(beta?: true))
assert_equal "#{@signup.company_name} (Beta)", @signup.send(:queenbee_account_name)
end
test "#process creates all the necessary objects for an existing identity" do
signal_identity = SignalId::Identity.find_by_email_address("david@37signals.com")
@signup = Signup.new(
signal_identity: signal_identity,
company_name: "Team LeMans",
)
Account.any_instance.expects(:setup_basic_template).once
assert @signup.process, @signup.errors.full_messages.to_sentence(words_connector: ". ")
assert_empty @signup.errors
assert @signup.signal_identity
assert_equal signal_identity, @signup.signal_identity
assert @signup.queenbee_account
assert @signup.queenbee_account.id
assert @signup.signal_account
assert @signup.signal_account.persisted?
assert_equal @signup.signal_identity, @signup.signal_account.owner.identity
assert @signup.account
assert @signup.account.persisted?
assert @signup.user
assert @signup.user.persisted?
assert_equal @signup.queenbee_account.id.to_s, @signup.tenant_name
assert_includes ApplicationRecord.tenants, @signup.tenant_name
end
test "#process does nothing if a validation error occurs creating identity" do
@signup.password = ""
assert_not @signup.process
assert_not_empty @signup.errors[:password]
assert_nil @signup.signal_identity
assert_nil @signup.queenbee_account
assert_nil @signup.account
assert_nil @signup.user
end
test "#process does nothing if a validation error occurs creating the queenbee account" do
Queenbee::Remote::Account.stubs(:create!).raises(RuntimeError, "Invalid account data")
SignalId::Identity.any_instance.expects(:destroy).once
assert_not @signup.process
assert_not_empty @signup.errors[:base]
assert_nil @signup.signal_identity
assert_nil @signup.queenbee_account
assert_nil @signup.account
assert_nil @signup.user
end
test "#process does nothing if a validation error occurs creating the tenant" do
ApplicationRecord.stubs(:create_tenant).raises(RuntimeError, "Tenant already exists")
Queenbee::Remote::Account.any_instance.expects(:cancel).once
SignalId::Identity.any_instance.expects(:destroy).once
assert_not @signup.process
assert_not_empty @signup.errors[:base]
assert_nil @signup.signal_identity
assert_nil @signup.queenbee_account
assert_nil @signup.account
assert_nil @signup.user
end
test "#process does nothing if a validation error occurs creating the account" do
Account.stubs(:create_with_admin_user).raises(RuntimeError, "Account creation failed")
ApplicationRecord.expects(:destroy_tenant).once
Queenbee::Remote::Account.any_instance.expects(:cancel).once
SignalId::Identity.any_instance.expects(:destroy).once
assert_not @signup.process
assert_not_empty @signup.errors[:base]
assert_nil @signup.signal_identity
assert_nil @signup.queenbee_account
assert_nil @signup.account
assert_nil @signup.user
end
end
-7
View File
@@ -1,12 +1,5 @@
require "signal_id/testing"
require "queenbee/testing/mocks"
module ActiveSupport
class TestCase
include SignalId::Testing
end
end
Queenbee::Remote::Account.class_eval do
# because we use the account ID as the tenant name, we need it to be unique in each test to avoid
# parallelized tests clobbering each other.
+15 -16
View File
@@ -13,27 +13,26 @@ unless Rails.env.local?
end
identifier = SecureRandom.hex(4)
signup = Signup.new(
email_address: "dev-#{identifier}@example.com",
full_name: "Developer #{identifier}",
company_name: "Company #{identifier}",
password: "secret123456"
)
tenant = ActiveRecord::FixtureSet.identify(identifier)
puts "Creating signal identity for #{signup.email_address}..."
signup.send(:create_signal_identity)
puts "Creating queenbee account ..."
signup.send(:create_queenbee_account)
path = ApplicationRecord.tenanted_root_config.database_path_for(signup.tenant_name)
path = ApplicationRecord.tenanted_root_config.database_path_for(tenant)
FileUtils.mkdir_p(File.dirname(path), verbose: true)
FileUtils.cp original_dbfile, path, verbose: true
ActiveRecord::Tenanted::DatabaseTasks.migrate_all
ApplicationRecord.with_tenant(signup.tenant_name) do |tenant|
Account.sole.update! external_account: signup.signal_account
ApplicationRecord.with_tenant(tenant) do |tenant|
Account.sole.destroy!
puts "\n\nLogin to http://launchpad.localhost:3011/ as #{signup.email_address} / #{signup.password}"
Account.create_with_admin_user(
tenant_id: tenant,
account_name: "Company #{identifier}",
owner_name: "Developer #{identifier}",
owner_email: "dev-#{identifier}@example.com")
user = User.last
user.update! password: "secret123456"
url = Rails.application.routes.url_helpers.root_url(Rails.application.config.action_controller.default_url_options.merge(script_name: Account.sole.slug))
puts "\n\nLogin to #{url} as #{user.email_address} / #{user.password}"
end
@@ -1,53 +0,0 @@
#!/usr/bin/env ruby
#
# set up a temporary password for all users
# while we migrate away from Launchpad and 37id.
#
# the password is unguessable but generated based on the email address and a seed,
# so each user will have the same password across all tenants.
#
require_relative "../../config/environment"
seed = ARGV[0] || "temporary-password-seed"
puts "Using seed: #{seed.inspect}"
TENANTS = Hash.new
USERS = Hash.new { |h, k| h[k] = Hash.new }
ApplicationRecord.with_each_tenant do |tenant|
TENANTS[tenant] = Account.sole.name
User.find_each do |user|
putc "."
next if user.system?
if user.external_user_id
suser = SignalId::User.find_by_id(user.external_user_id)
if suser && suser.email_address != user.email_address
puts "\nWarning: fixing email address for user #{user.id} in tenant #{tenant}:"
puts " local: #{user.email_address}"
puts " signal: #{suser.email_address}"
user.update! email_address: suser.email_address
end
end
password = Digest::SHA256.hexdigest("#{seed}-#{user.email_address}")[0..16]
user.update! password: password
USERS[user.email_address][tenant] = password
end
end
puts
USERS.each do |email, hash|
puts "\n#{email}:"
puts " password: #{hash.first.last}"
puts " fizzies:"
hash.each do |tenant, _|
url = Rails.application.routes.url_helpers.root_url(Rails.application.config.action_controller.default_url_options.merge(script_name: "/#{tenant}"))
puts " #{TENANTS[tenant]}: #{url}"
end
end
-117
View File
@@ -1,117 +0,0 @@
#!/usr/bin/env ruby
require_relative "../config/environment"
ActiveRecord::Base.logger = Logger.new(File::NULL)
class BootstrapSignalId
def initialize(dry_run: false)
@dry_run = dry_run
end
def run
SignalId::Database.on_master do
ApplicationRecord.with_each_tenant do |tenant|
puts "\n# tenant: #{tenant}"
next unless check_account_preconditions
create_signal_id_account if Account.sole.queenbee_id.nil?
create_signal_id_users
end
end
end
def check_account_preconditions
unless Account.count == 1
puts "There are #{Account.count} accounts, but exactly one is expected."
false
else
true
end
end
def create_signal_id_account
owner = SignalId::Identity.find_by_email_address!("kevin@37signals.com")
print_identity("New owner is:", owner)
unless @dry_run
qbattr = queenbee_account_attributes(owner)
queenbee_account = Queenbee::Remote::Account.create!(qbattr)
signal_id_account = SignalId::Account.find_by!(queenbee_id: queenbee_account.id)
signal_id_account.update_column :subdomain, ApplicationRecord.current_tenant
account = Account.sole
account.queenbee_id = queenbee_account.id
account.name = ApplicationRecord.current_tenant
account.save!
end
end
def create_signal_id_users
signal_account = Account.sole.signal_account
User.find_each do |user|
if !user.system? && user.signal_user_id.nil?
signal_identities = SignalId::Identity.where(email_address: user.email_address)
if signal_identities.length > 1
puts "Multiple identities found for #{user.email_address}:"
signal_identities.each { |identity| print_identity(" - ", identity) }
signal_identity = signal_identities.first
elsif signal_identities.length == 1
signal_identity = signal_identities.first
print_identity("Identity for #{user.email_address}:", signal_identity)
else
puts "No identity found for #{user.name} (#{user.email_address})"
signal_identity = nil
end
if signal_identity
unless @dry_run
signal_user = SignalId::User.find_or_create_by!(identity: signal_identity, account: signal_account)
user.signal_user_id = signal_user.id
user.save!
end
end
end
end
end
def print_identity(message = "Identity:", identity)
pad = " " * message.length
puts "#{message} #{identity.name} (#{identity.email_address})"
puts "#{pad } ID: #{identity.id}"
puts "#{pad } Username: #{identity.username}"
end
def queenbee_account_attributes(signal_identity)
{
skip_remote: true, # Fizzy creates its own local account
product_name: "fizzy",
name: account_name,
owner_identity_id: signal_identity.id,
trial: false,
subscription: subscription_attributes,
remote_request: request_attributes
}
end
def subscription_attributes
subscription = FreeV1Subscription
{ name: subscription.to_param, price: subscription.price }
end
def request_attributes
{ user_agent: "script/bootstrap-signal-id.rb" }
end
def account_name
name = ApplicationRecord.current_tenant
name += " (Beta)" if Rails.env.beta?
name
end
end
BootstrapSignalId.new(dry_run: false).run
+23
View File
@@ -5,4 +5,27 @@ class AccountTest < ActiveSupport::TestCase
account = Account.sole
assert_equal "/#{ApplicationRecord.current_tenant}", account.slug
end
test ".create_with_admin_user creates a new local account" do
ApplicationRecord.create_tenant("account-create-with-dependents") do
account = Account.create_with_admin_user(
tenant_id: ActiveRecord::FixtureSet.identify("account-create-with-admin-user-test"),
account_name: "Account Create With Admin",
owner_name: "David",
owner_email: "david@37signals.com")
assert_not_nil account
assert account.persisted?
assert_equal 1, Account.count
assert_equal ActiveRecord::FixtureSet.identify("account-create-with-admin-user-test"), account.tenant_id
assert_equal "Account Create With Admin", account.name
assert_equal 1, User.count
User.first.tap do |user|
assert_equal "David", user.name
assert_equal "david@37signals.com", user.email_address
assert_equal "admin", user.role
end
end
end
end