Merge pull request #1277 from basecamp/flavorjones/identity-and-membership

Introduce an "Identity" model to ease login
This commit is contained in:
Mike Dalessio
2025-10-10 10:35:05 -04:00
committed by GitHub
24 changed files with 336 additions and 32 deletions
+1
View File
@@ -57,6 +57,7 @@
.icon--lifebuoy { --svg: url("lifebuoy.svg "); }
.icon--lock { --svg: url("lock.svg "); }
.icon--logout { --svg: url("logout.svg "); }
.icon--logo-color { --svg: url("logo-color.svg "); mask-size: contain; }
.icon--menu { --svg: url("menu.svg "); }
.icon--menu-dots-horizontal { --svg: url("menu-dots-horizontal.svg "); }
.icon--menu-dots-vertical { --svg: url("menu-dots-vertical.svg "); }
@@ -38,6 +38,7 @@ module Authentication
def require_tenant
unless ApplicationRecord.current_tenant.present?
set_current_identity_token
render "sessions/login_menu"
end
end
@@ -77,14 +78,27 @@ module Authentication
end
def start_new_session_for(user)
link_identity(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
set_current_session session
end
end
def link_identity(user)
token_value = cookies.signed[:identity_token]
token_identity = Identity.find_signed(token_value["id"]) if token_value.present?
identity = user.set_identity(token_identity)
cookies.signed.permanent[:identity_token] = { value: { "id" => identity.signed_id, "updated_at" => identity.updated_at }, httponly: true, same_site: :lax }
end
def set_current_identity_token
Current.identity_token = Identity::Mock.new(**cookies.signed[:identity_token])
end
def set_current_session(session)
logger.struct " Authorized User##{session.user.id}", authentication: { user: { id: session.user.id } }
Current.session = session
set_current_identity_token
cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax, path: Account.sole.slug }
end
+3 -2
View File
@@ -15,10 +15,11 @@ module FiltersHelper
user_filtering.selected_collection_titles.collect { tag.strong it }.to_sentence.html_safe
end
def filter_place_menu_item(path, label, icon)
def filter_place_menu_item(path, label, icon, new_window: false)
link_to_params = new_window ? { target: "_blank" } : {}
tag.li class: "popup__item", id: "filter-place-#{label.parameterize}", data: { filter_target: "item", navigable_list_target: "item" } do
concat icon_tag(icon, class: "popup__icon")
concat(link_to(path, class: "popup__btn btn") do
concat(link_to(path, link_to_params.merge(class: "popup__btn btn")) do
concat tag.span(label, class: "overflow-ellipsis")
end)
end
+1 -1
View File
@@ -1,6 +1,6 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session
attribute :http_method, :request_id, :user_agent, :ip_address, :referrer
attribute :http_method, :request_id, :user_agent, :ip_address, :referrer, :identity_token
delegate :user, to: :session, allow_nil: true
end
+7
View File
@@ -0,0 +1,7 @@
class Identity < UntenantedRecord
# This is used to instantiate an Identity-like object from the `identity_token` without hitting
# the untenanted database. It is intended to be used with caching/etagging methods.
Mock = Struct.new(:id, :updated_at)
has_many :memberships, dependent: :destroy
end
+13
View File
@@ -0,0 +1,13 @@
class Membership < UntenantedRecord
belongs_to :identity, touch: true
# I want this to be `belongs_to :user`, but ActiveRecord::Tenanted doesn't yet support
# associations from untenanted to untenanted models.
#
# See https://github.com/basecamp/activerecord-tenanted/issues/201
#
# In the meantime, when creating a Membership, specify both `user_id` and `user_tenant` attributes.
def user
User.with_tenant(user_tenant) { User.find_by(id: user_id) }
end
end
+4
View File
@@ -0,0 +1,4 @@
class UntenantedRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: { writing: :untenanted }
end
+1 -1
View File
@@ -1,6 +1,6 @@
class User < ApplicationRecord
include Accessor, AiQuota, Assignee, Attachable, Configurable, Conversational, Highlights,
Mentionable, Named, Notifiable, Role, Searcher, Staff, Transferable, Watcher
Identifiable, Mentionable, Named, Notifiable, Role, Searcher, Staff, Transferable, Watcher
include Timelined # Depends on Accessor
has_one_attached :avatar
+28
View File
@@ -0,0 +1,28 @@
module User::Identifiable
extend ActiveSupport::Concern
included do
has_one :membership, ->(user) { where(user_tenant: user.tenant) }
has_one :identity, through: :membership
end
def set_identity(token_identity)
if token_identity.present?
if identity.nil?
token_identity.memberships.create!(user_id: id, user_tenant: tenant, email_address: email_address, account_name: Account.sole.name)
elsif identity != token_identity
Identity.transaction do
identity.memberships.update_all(identity_id: token_identity.id)
identity.destroy
end
end
token_identity
elsif identity.present?
identity
else
Identity.create!.tap do |identity|
identity.memberships.create!(user_id: id, user_tenant: tenant, email_address: email_address, account_name: Account.sole.name)
end
end
end
end
+1 -1
View File
@@ -3,5 +3,5 @@
action: "click->dialog#open:stop keydown.j@document->hotkey#click keydown.meta+j@document->hotkey#click keydown.ctrl+j@document->hotkey#click",
controller: "hotkey" } do %>
<%= inline_svg "fizzy-logo" %>
<strong class="txt-medium overflow-ellipsis margin-none">Fizzy</strong>
<strong class="txt-medium overflow-ellipsis margin-none"><%= Account.sole.name %></strong>
<% end %>
+5
View File
@@ -3,4 +3,9 @@
<%= filter_place_menu_item user_path(Current.user), "My Profile", "person" %>
<%= filter_place_menu_item notifications_path, "Notifications", "bell" %>
<%= filter_place_menu_item notifications_settings_path, "Notification Settings", "settings" %>
<% cache [ Current.user, Current.identity_token ] do %>
<% Current.user.identity.memberships.where.not(user_tenant: Current.user.tenant).each do |membership| %>
<%= filter_place_menu_item root_url(script_name: "/#{membership.user_tenant}"), "#{membership.account_name}", "logo-color", new_window: true %>
<% end %>
<% end %>
<% end %>
+21 -14
View File
@@ -1,15 +1,22 @@
<% cache ApplicationRecord.tenants do %>
<h2>We're migrating Fizzy authentication</h2>
<p>
While we migrate Fizzy away from Launchpad and 37id, please login at one of the following URLs:
<% @page_title = "Choose Account" %>
<ul style="list-style-type: none;">
<% ApplicationRecord.with_each_tenant do |tenant| %>
<li><%= link_to Account.sole.name, root_url(script_name: Account.sole.slug) %></li>
<% end %>
</ul>
</p>
<p>
Mike should have sent you your temporary password by now. If you haven't received it, please ping him!
</p>
<% end %>
<div class="panel shadow center margin-block-double" style="--panel-size: 55ch;">
<h1 class="txt-xx-large margin-block-end-double">Fizzy</h1>
<% cache [ Current.user, Current.identity_token ] do %>
<% identity = Identity.find_signed(Current.identity_token.id) %>
<% memberships = identity&.memberships %>
<% if memberships.present? %>
<h2 class="txt-large margin-block-end">Your Fizzy Accounts</h2>
<ul class="flex flex-column gap txt-large" style="list-style-type: none; padding: 0;">
<% memberships.each do |membership| %>
<li>
<%= link_to membership.account_name, root_url(script_name: "/#{membership.user_tenant}"), class: "btn btn--reversed center" %>
</li>
<% end %>
</ul>
<% else %>
<p class="txt-large txt-align-center txt-subtle">You don't have any existing Fizzy accounts.</p>
<% end %>
<% end %>
</div>
-4
View File
@@ -24,7 +24,3 @@
</button>
<% end %>
</div>
<% content_for :footer do %>
<div class="txt-align-center center margin-block-double txt-subtle">Fizzy&trade; version 0</div>
<% end %>
+15 -7
View File
@@ -16,6 +16,10 @@ development:
<<: *default
database: storage/tenants/<%= Rails.env %>/%{tenant}/db/main.sqlite3
tenanted: true
untenanted:
<<: *default
database: storage/untenanted/development.sqlite3
migrations_paths: db/untenanted_migrate
cable:
<<: *default
database: storage/development_cable.sqlite3
@@ -33,9 +37,14 @@ development:
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
<<: *default
database: storage/tenants/<%= Rails.env %>/%{tenant}/db/main.sqlite3
tenanted: true
primary:
<<: *default
database: storage/tenants/<%= Rails.env %>/%{tenant}/db/main.sqlite3
tenanted: true
untenanted:
<<: *default
database: storage/untenanted/test.sqlite3
migrations_paths: db/untenanted_migrate
production: &production
primary:
@@ -44,11 +53,10 @@ production: &production
tenanted: true
extensions:
- <%= SqliteVec.loadable_path %>
primary_original:
untenanted:
<<: *default
database: storage/production.sqlite3
replica: true
readonly: true
database: storage/untenanted/production.sqlite3
migrations_paths: db/untenanted_migrate
cable:
<<: *default
database: storage/production_cable.sqlite3
@@ -0,0 +1,7 @@
class CreateIdentities < ActiveRecord::Migration[8.1]
def change
create_table :identities do |t|
t.timestamps
end
end
end
@@ -0,0 +1,15 @@
class CreateMemberships < ActiveRecord::Migration[8.1]
def change
create_table :memberships do |t|
t.string :email_address, null: false
t.references :identity, null: false, foreign_key: true
t.references :user, null: false, foreign_key: false, index: false
t.string :user_tenant, null: false
t.string :account_name, null: false
t.timestamps
end
add_index :memberships, :email_address
add_index :memberships, [ :user_tenant, :user_id ]
end
end
+33
View File
@@ -0,0 +1,33 @@
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_09_24_190729) do
create_table "identities", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "memberships", force: :cascade do |t|
t.string "account_name", null: false
t.datetime "created_at", null: false
t.string "email_address", null: false
t.integer "identity_id", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.string "user_tenant", null: false
t.index ["email_address"], name: "index_memberships_on_email_address"
t.index ["identity_id"], name: "index_memberships_on_identity_id"
t.index ["user_tenant", "user_id"], name: "index_memberships_on_user_tenant_and_user_id"
end
add_foreign_key "memberships", "identities"
end
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env ruby
require_relative "../../config/environment"
ApplicationRecord.with_each_tenant do |tenant|
puts "# #{tenant}"
User.find_each do |user|
next if user.system? || !user.active?
if user.membership.present?
puts "Found identity #{user.identity.id} for user #{user.id} (#{user.email_address})"
else
memberships = Membership.where(email_address: user.email_address)
if memberships.empty?
# Create a new Identity
Identity.transaction do
identity = Identity.create!
user.membership = identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: Account.sole.name)
puts "Created identity #{identity.id} for user #{user.id} (#{user.email_address})"
end
else
# Merge this User's Membership into the existing Identity
identity = memberships.first.identity
user.membership = identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: Account.sole.name)
puts "Merged membership for user #{user.id} (#{user.email_address}) into identity #{identity.id}"
end
end
end
end
@@ -7,7 +7,7 @@ class ControllerAuthenticationTest < ActionDispatch::IntegrationTest
get cards_path
assert_response :success
assert_dom "h2", text: "We're migrating Fizzy authentication"
assert_dom "p", text: "You don't have any existing Fizzy accounts."
end
test "access with an account slug but no session redirects to new session" do
+5 -1
View File
@@ -17,7 +17,11 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
end
test "create with valid credentials" do
post session_path, params: { email_address: "david@37signals.com", password: "secret123456" }
assert_difference -> { Identity.count }, 1 do
assert_difference -> { Membership.count }, 1 do
post session_path, params: { email_address: "david@37signals.com", password: "secret123456" }
end
end
assert_redirected_to root_path
assert cookies[:session_token].present?
@@ -0,0 +1,37 @@
require "test_helper"
class IdentityMembershipTest < ActionDispatch::IntegrationTest
setup do
@tenants = [ ActiveRecord::FixtureSet.identify("identity-tenant-1"),
ActiveRecord::FixtureSet.identify("identity-tenant-2") ]
@tenant_paths = @tenants.map { "/#{_1}" }
@tenant_urls = @tenant_paths.map { root_url(script_name: _1) }
@user_params = { email_address: "user@example.com", password: "password1234" }
@users = @tenants.map do |tenant|
ApplicationRecord.create_tenant(tenant) do
Account.create! name: "Account for #{tenant}"
User.create! @user_params.merge(name: "Harold Hancox")
end
end
end
test "multiple signins on the same browser" do
post session_path(script_name: @tenant_paths[0], params: @user_params)
assert_redirected_to root_path(script_name: @tenant_paths[0])
post session_path(script_name: @tenant_paths[1], params: @user_params)
assert_redirected_to root_path(script_name: @tenant_paths[1])
# Render links for other Fizzies in the jump menu
get my_menu_path(script_name: @tenant_paths[0])
assert_select "#my_menu ul li a[href='#{@tenant_urls[1]}']", "Account for #{@tenants[1]}"
get my_menu_path(script_name: @tenant_paths[1])
assert_select "#my_menu ul li a[href='#{@tenant_urls[0]}']", "Account for #{@tenants[0]}"
# Render links for all the identity's Fizzies
get root_path(script_name: nil)
assert_select "ul li a[href='#{@tenant_urls[0]}']", "Account for #{@tenants[0]}"
assert_select "ul li a[href='#{@tenant_urls[1]}']", "Account for #{@tenants[1]}"
end
end
+7
View File
@@ -0,0 +1,7 @@
require "test_helper"
class IdentityTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end
+7
View File
@@ -0,0 +1,7 @@
require "test_helper"
class MembershipTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end
+81
View File
@@ -0,0 +1,81 @@
require "test_helper"
class User::IdentifiableTest < ActiveSupport::TestCase
test "set_identity when token is an identity and user has a matching identity" do
user = users(:david)
token_identity = Identity.create!
token_identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: "asdf")
assert_equal(token_identity, user.identity)
result = user.set_identity(token_identity)
user.reload
assert_equal(token_identity, result)
assert_equal(token_identity, user.identity)
end
test "set_identity when token is an identity and user has no identity" do
user = users(:david)
token_identity = Identity.create!
assert_nil(user.membership)
assert_nil(user.identity)
result = user.set_identity(token_identity)
user.reload
assert_equal(token_identity, result)
assert_equal(token_identity, user.identity)
assert_equal(user.email_address, user.membership.email_address)
assert_equal(Account.sole.name, user.membership.account_name)
end
test "set_identity when token is an identity and user has a different identity" do
user = users(:david)
user2 = users(:jz)
token_identity = Identity.create!
user_identity = Identity.create!
user_identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: "asdf")
user_identity.memberships.create!(user_id: user2.id, user_tenant: user2.tenant, email_address: user2.email_address, account_name: "qwer")
assert_equal(user_identity, user.identity)
assert_equal(user_identity, user2.identity)
result = user.set_identity(token_identity)
user.reload
user2.reload
assert_equal(token_identity, result)
assert_equal(token_identity, user.identity)
assert_equal(token_identity, user2.identity)
assert_raises(ActiveRecord::RecordNotFound) do
user_identity.reload
end
end
test "set_identity when token is nil and user has an identity" do
user = users(:david)
user_identity = Identity.create!
user_identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: "asdf")
assert_equal(user_identity, user.identity)
result = user.set_identity(nil)
user.reload
assert_equal(user_identity, result)
assert_equal(user_identity, user.identity)
end
test "set_identity when token is nil and user has no identity" do
user = users(:david)
assert_nil(user.identity)
result = user.set_identity(nil)
user.reload
assert_not_nil(result)
assert_equal(result, user.identity)
assert_equal(1, result.memberships.count)
assert_equal(user.email_address, user.membership.email_address)
assert_equal(Account.sole.name, user.membership.account_name)
end
end