Skip to content

Commit 088d3eb

Browse files
committed
TDD step 10: Install generator
`bin/rails generate active_admin:oidc:install` now produces the three things a host app actually needs: a commented-out initializer (with sample on_login snippets for Zitadel nested roles and for department-style apps to prove the gem is authorization-agnostic), a migration adding provider/uid/oidc_raw_info + unique index to admin_users, and the published login view override. Notable choices: migration is idempotent by file-glob (not by timestamp), jsonb on Postgres / text elsewhere, and the generator refuses to run without AdminUser / devise / activeadmin in the host app so silent half-installs can't happen.
1 parent 4d01b7e commit 088d3eb

5 files changed

Lines changed: 358 additions & 0 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# frozen_string_literal: true
2+
3+
require "rails/generators/base"
4+
require "rails/generators/active_record"
5+
6+
module ActiveAdmin
7+
module Oidc
8+
module Generators
9+
# `bin/rails generate active_admin:oidc:install`
10+
#
11+
# Produces a working starting point for the host app:
12+
#
13+
# * config/initializers/activeadmin_oidc.rb (commented template)
14+
# * db/migrate/<ts>_add_oidc_to_admin_users.rb
15+
# * app/views/active_admin/devise/sessions/new.html.erb (override)
16+
#
17+
# Idempotent: running twice is a no-op for all three files (the
18+
# migration is skipped if an *_add_oidc_to_admin_users.rb file
19+
# already exists on disk, even with a different timestamp).
20+
#
21+
# Refuses to run if the host app is missing an `AdminUser` model
22+
# or the `devise` / `activeadmin` gems — those are the sharp
23+
# corners it can't work around, and silently succeeding would
24+
# produce broken configuration.
25+
class InstallGenerator < ::Rails::Generators::Base
26+
include ::Rails::Generators::Migration
27+
28+
source_root File.expand_path("templates", __dir__)
29+
30+
desc "Installs activeadmin-oidc into the host Rails app."
31+
32+
def self.next_migration_number(dirname)
33+
::ActiveRecord::Generators::Base.next_migration_number(dirname)
34+
end
35+
36+
def verify_host_app!
37+
unless File.exist?(File.join(destination_root, "app/models/admin_user.rb"))
38+
raise ::Thor::Error,
39+
"activeadmin-oidc: could not find app/models/admin_user.rb — " \
40+
"install activeadmin + devise first and make sure the AdminUser " \
41+
"model exists."
42+
end
43+
44+
gemfile_lock = File.join(destination_root, "Gemfile.lock")
45+
lock_contents = File.exist?(gemfile_lock) ? File.read(gemfile_lock) : ""
46+
47+
unless lock_contents.match?(/^\s+devise\b/)
48+
raise ::Thor::Error,
49+
"activeadmin-oidc: devise not found in Gemfile.lock. " \
50+
"Add `gem \"devise\"` and run `bundle install` first."
51+
end
52+
53+
unless lock_contents.match?(/^\s+activeadmin\b/)
54+
raise ::Thor::Error,
55+
"activeadmin-oidc: activeadmin not found in Gemfile.lock. " \
56+
"Add `gem \"activeadmin\"` and run `bundle install` first."
57+
end
58+
end
59+
60+
def create_initializer
61+
template "initializer.rb.tt", "config/initializers/activeadmin_oidc.rb"
62+
end
63+
64+
def create_migration_file
65+
# Idempotency: if a previous run already produced this
66+
# migration (any timestamp), do nothing. We never want to
67+
# stack up duplicate add_column migrations.
68+
existing = Dir[File.join(destination_root, "db/migrate/*_add_oidc_to_admin_users.rb")]
69+
return if existing.any?
70+
71+
@raw_info_type = raw_info_column_type
72+
migration_template "migration.rb.tt",
73+
"db/migrate/add_oidc_to_admin_users.rb"
74+
end
75+
76+
def create_view_override
77+
copy_file "sessions_new.html.erb",
78+
"app/views/active_admin/devise/sessions/new.html.erb"
79+
end
80+
81+
private
82+
83+
# Postgres gets `jsonb` (fast, indexable), everything else
84+
# falls back to `:text` so sqlite/mysql hosts aren't left out.
85+
def raw_info_column_type
86+
adapter = (ActiveRecord::Base.connection_db_config.adapter rescue "sqlite3").to_s
87+
adapter.start_with?("postgres") ? ":jsonb" : ":text"
88+
end
89+
end
90+
end
91+
end
92+
end
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
# activeadmin-oidc initializer — generated by
4+
# bin/rails generate active_admin:oidc:install
5+
#
6+
# Uncomment and fill in the values for your identity provider.
7+
8+
ActiveAdmin::Oidc.configure do |c|
9+
# --- Transport-layer defaults for Zitadel ---------------------------
10+
# Sets `scope = "openid email profile"` and enables PKCE automatically
11+
# when no client_secret is configured (public-client mode).
12+
# c.preset :zitadel
13+
14+
# --- Provider ---------------------------------------------------------
15+
# c.issuer = ENV.fetch("OIDC_ISSUER")
16+
# c.client_id = ENV.fetch("OIDC_CLIENT_ID")
17+
# c.client_secret = ENV.fetch("OIDC_CLIENT_SECRET", nil) # blank = PKCE public client
18+
19+
# --- Identity lookup --------------------------------------------------
20+
# Which AdminUser column to match against when a (provider, uid) lookup
21+
# misses and we need to migrate an existing row to SSO, and which claim
22+
# on the id_token/userinfo to read it from.
23+
# c.identity_attribute = :email
24+
# c.identity_claim = :email
25+
26+
# --- Login button label ----------------------------------------------
27+
# c.login_button_label = "Sign in with Corporate SSO"
28+
29+
# --- on_login hook ---------------------------------------------------
30+
# Called with (admin_user, claims) after identity lookup and before
31+
# save. Mutate admin_user in place, return truthy to allow sign-in,
32+
# return falsy to deny. This is the ONLY place authorization lives —
33+
# the gem does not ship a role model.
34+
#
35+
# Example A — Zitadel with nested project roles claim:
36+
#
37+
# c.on_login = ->(admin_user, claims) {
38+
# roles = claims["urn:zitadel:iam:org:project:roles"]&.keys || []
39+
# return false if roles.empty?
40+
# admin_user.roles = roles
41+
# true
42+
# }
43+
#
44+
# Example B — department-style authorization:
45+
#
46+
# KNOWN_DEPARTMENTS = %w[ops eng support].freeze
47+
# c.on_login = ->(admin_user, claims) {
48+
# dept = claims["department"]
49+
# return false unless KNOWN_DEPARTMENTS.include?(dept)
50+
# admin_user.department = dept
51+
# true
52+
# }
53+
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
class AddOidcToAdminUsers < ActiveRecord::Migration[7.0]
4+
def change
5+
add_column :admin_users, :provider, :string
6+
add_column :admin_users, :uid, :string
7+
add_column :admin_users, :oidc_raw_info, <%= @raw_info_type %>
8+
9+
add_index :admin_users, [:provider, :uid], unique: true
10+
end
11+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<div id="login">
2+
<h2><%%= active_admin_application.site_title(self) %></h2>
3+
4+
<%%= button_to ActiveAdmin::Oidc.config.login_button_label,
5+
"/admin/auth/oidc",
6+
method: :post,
7+
class: "activeadmin-oidc-login-button",
8+
data: { turbo: false } %>
9+
</div>
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
require "rails/generators"
5+
require "generators/active_admin/oidc/install/install_generator"
6+
7+
# The install generator is what a host app runs once:
8+
#
9+
# bundle add activeadmin-oidc
10+
# bin/rails generate active_admin:oidc:install
11+
#
12+
# It must produce a working, editable starting point — initializer with
13+
# a commented-out Zitadel preset and sample `on_login` snippets, a
14+
# migration adding `provider`/`uid`/`oidc_raw_info` + a unique index to
15+
# `admin_users`, and a published login view override. It must be
16+
# idempotent (running twice does nothing bad) and refuse to run if the
17+
# host app doesn't have `AdminUser` or `devise`/`activeadmin`.
18+
RSpec.describe ActiveAdmin::Oidc::Generators::InstallGenerator do
19+
let(:destination_root) { File.expand_path("../../tmp/generator_test", __dir__) }
20+
21+
before do
22+
FileUtils.rm_rf(destination_root)
23+
FileUtils.mkdir_p(destination_root)
24+
# Stand up a minimal fake host-app tree so the generator can find
25+
# config/routes.rb, config/initializers, db/migrate, Gemfile.lock
26+
# and the AdminUser model it needs to refuse-to-run without.
27+
FileUtils.mkdir_p(File.join(destination_root, "config/initializers"))
28+
FileUtils.mkdir_p(File.join(destination_root, "db/migrate"))
29+
FileUtils.mkdir_p(File.join(destination_root, "app/models"))
30+
File.write(
31+
File.join(destination_root, "config/routes.rb"),
32+
<<~RUBY
33+
Rails.application.routes.draw do
34+
ActiveAdmin.routes(self)
35+
end
36+
RUBY
37+
)
38+
File.write(
39+
File.join(destination_root, "app/models/admin_user.rb"),
40+
"class AdminUser < ApplicationRecord; end\n"
41+
)
42+
File.write(
43+
File.join(destination_root, "Gemfile.lock"),
44+
<<~LOCK
45+
GEM
46+
specs:
47+
devise (5.0.3)
48+
activeadmin (3.5.1)
49+
LOCK
50+
)
51+
end
52+
53+
def run_generator(args = [])
54+
# Instantiate directly rather than going through `start` so that
55+
# `Thor::Error` raised in preflight propagates to the spec instead
56+
# of being caught by Thor's CLI wrapper. Redirect $stdout so the
57+
# "create file" log lines don't pollute rspec output.
58+
generator = described_class.new(args, [], destination_root: destination_root)
59+
orig_stdout = $stdout
60+
$stdout = StringIO.new
61+
begin
62+
generator.invoke_all
63+
ensure
64+
$stdout = orig_stdout
65+
end
66+
end
67+
68+
describe "initializer" do
69+
before { run_generator }
70+
71+
let(:initializer) do
72+
File.read(File.join(destination_root, "config/initializers/activeadmin_oidc.rb"))
73+
end
74+
75+
it "creates the initializer at config/initializers/activeadmin_oidc.rb" do
76+
expect(File).to exist(
77+
File.join(destination_root, "config/initializers/activeadmin_oidc.rb")
78+
)
79+
end
80+
81+
it "contains an ActiveAdmin::Oidc.configure block" do
82+
expect(initializer).to include("ActiveAdmin::Oidc.configure do |c|")
83+
end
84+
85+
it "includes a commented-out Zitadel preset line" do
86+
expect(initializer).to match(/^\s*#\s*c\.preset\s+:zitadel/)
87+
end
88+
89+
it "includes commented-out ENV reads for issuer/client_id/client_secret" do
90+
expect(initializer).to match(/#\s*c\.issuer\s*=\s*ENV/)
91+
expect(initializer).to match(/#\s*c\.client_id\s*=\s*ENV/)
92+
expect(initializer).to match(/#\s*c\.client_secret\s*=\s*ENV/)
93+
end
94+
95+
it "includes a sample on_login snippet for Zitadel nested roles claim" do
96+
expect(initializer).to include("urn:zitadel:iam:org:project:roles")
97+
end
98+
99+
it "includes a sample on_login snippet for a department-style assignment" do
100+
expect(initializer).to match(/department/i)
101+
end
102+
end
103+
104+
describe "migration" do
105+
before { run_generator }
106+
107+
let(:migration_path) do
108+
Dir[File.join(destination_root, "db/migrate/*_add_oidc_to_admin_users.rb")].first
109+
end
110+
111+
let(:migration) { File.read(migration_path) }
112+
113+
it "creates a timestamped migration adding oidc columns to admin_users" do
114+
expect(migration_path).not_to be_nil
115+
end
116+
117+
it "adds provider, uid, and oidc_raw_info columns" do
118+
expect(migration).to match(/add_column\s+:admin_users,\s*:provider,\s*:string/)
119+
expect(migration).to match(/add_column\s+:admin_users,\s*:uid,\s*:string/)
120+
expect(migration).to match(/add_column\s+:admin_users,\s*:oidc_raw_info/)
121+
end
122+
123+
it "does not add any authorization column (no :role, :roles, :department)" do
124+
expect(migration).not_to match(/:roles?\b/)
125+
expect(migration).not_to match(/:department/)
126+
end
127+
128+
it "adds a unique index on (provider, uid)" do
129+
expect(migration).to match(
130+
/add_index\s+:admin_users,\s*\[:provider,\s*:uid\],\s*unique:\s*true/
131+
)
132+
end
133+
end
134+
135+
describe "login view override" do
136+
before { run_generator }
137+
138+
it "publishes app/views/active_admin/devise/sessions/new.html.erb" do
139+
expect(File).to exist(
140+
File.join(destination_root, "app/views/active_admin/devise/sessions/new.html.erb")
141+
)
142+
end
143+
end
144+
145+
describe "idempotency" do
146+
it "does not duplicate the initializer when run twice" do
147+
run_generator
148+
initializer_path = File.join(destination_root, "config/initializers/activeadmin_oidc.rb")
149+
first = File.read(initializer_path)
150+
151+
run_generator
152+
second = File.read(initializer_path)
153+
154+
expect(second).to eq(first)
155+
end
156+
157+
it "does not create a second migration when run twice" do
158+
run_generator
159+
first_migrations = Dir[File.join(destination_root, "db/migrate/*_add_oidc_to_admin_users.rb")]
160+
expect(first_migrations.size).to eq(1)
161+
162+
run_generator
163+
second_migrations = Dir[File.join(destination_root, "db/migrate/*_add_oidc_to_admin_users.rb")]
164+
expect(second_migrations.size).to eq(1)
165+
end
166+
end
167+
168+
describe "preflight checks" do
169+
it "aborts with a clear error if AdminUser model is missing" do
170+
FileUtils.rm(File.join(destination_root, "app/models/admin_user.rb"))
171+
172+
expect { run_generator }.to raise_error(Thor::Error, /AdminUser/)
173+
end
174+
175+
it "aborts with a clear error if devise is not in Gemfile.lock" do
176+
File.write(
177+
File.join(destination_root, "Gemfile.lock"),
178+
"GEM\n specs:\n activeadmin (3.5.1)\n"
179+
)
180+
181+
expect { run_generator }.to raise_error(Thor::Error, /devise/)
182+
end
183+
184+
it "aborts with a clear error if activeadmin is not in Gemfile.lock" do
185+
File.write(
186+
File.join(destination_root, "Gemfile.lock"),
187+
"GEM\n specs:\n devise (5.0.3)\n"
188+
)
189+
190+
expect { run_generator }.to raise_error(Thor::Error, /activeadmin/)
191+
end
192+
end
193+
end

0 commit comments

Comments
 (0)