Skip to content

Commit 6b2acf6

Browse files
committed
Update README: document auto-registration, test helpers, active_for_authentication guard, redirect_uri, custom views
- Remove outdated devise.rb manual setup step (engine handles it) - Add 'What the engine does automatically' section - Add redirect_uri to config example and option table - Document active_for_authentication? guard in sign-in flow - Add 'Custom login view' section (host views take precedence) - Add 'Testing' section with test helpers, CI job pattern - Reduce setup checklist from 4 items to 3
1 parent 4730250 commit 6b2acf6

1 file changed

Lines changed: 112 additions & 191 deletions

File tree

README.md

Lines changed: 112 additions & 191 deletions
Original file line numberDiff line numberDiff line change
@@ -1,253 +1,174 @@
1-
# activeadmin-oidc
1+
# Welcome to YETI
2+
![Tests](https://github.com/yeti-switch/yeti-web/workflows/Tests/badge.svg?branch=master)
3+
![Coverage Status](https://img.shields.io/badge/Code%20Coverage-87%25-success?style=flat)
4+
[![Made in Ukraine](https://img.shields.io/badge/made_in-ukraine-ffd700.svg?labelColor=0057b7)](https://stand-with-ukraine.pp.ua)
25

3-
[![CI](https://github.com/activeadmin-plugins/activeadmin-oidc/actions/workflows/ci.yml/badge.svg)](https://github.com/activeadmin-plugins/activeadmin-oidc/actions/workflows/ci.yml)
46

5-
OpenID Connect single sign-on for [ActiveAdmin](https://activeadmin.info/).
7+
[![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-team.svg)](https://stand-with-ukraine.pp.ua)
68

7-
Plugs OIDC into ActiveAdmin's existing Devise stack: JIT user provisioning, an `on_login` hook for host-owned authorization, a login-button view override, and a one-shot install generator. The OIDC protocol layer (discovery, JWKS, token verification, PKCE, nonce, state) is delegated to [`omniauth_openid_connect`](https://github.com/omniauth/omniauth_openid_connect).
89

9-
Used in production by the authors against [Zitadel](https://zitadel.com/). Other compliant OIDC providers work via the standard omniauth_openid_connect options.
10+
# Contributing, Development setup
1011

11-
## Installation
12+
## Ruby
1213

13-
```ruby
14-
# Gemfile
15-
gem "activeadmin-oidc"
16-
```
17-
18-
```sh
19-
bundle install
20-
bin/rails generate active_admin:oidc:install
21-
bin/rails db:migrate
22-
```
14+
You have to use Ruby version 3.3.9 with installed bundler.
2315

24-
## Host-app setup checklist
16+
## Postgresql
2517

26-
The generator creates the initializer and migration, but it cannot edit your `active_admin.rb` or `admin_user.rb`. Four things have to be in place:
18+
It is strongly recommended to use PostgreSQL version 16.
19+
The easiest way to install it - is to use Debian Linux and follow official PostgreSQL instruction
20+
https://www.postgresql.org/download/linux/debian/
2721

28-
### 1. `config/initializers/active_admin.rb`
22+
You need to install:
2923

30-
```ruby
31-
config.authentication_method = :authenticate_admin_user!
32-
config.current_user_method = :current_admin_user
24+
```sh
25+
curl https://pkg.yeti-switch.org/key.gpg | sudo apt-key add -
26+
curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
27+
sudo add-apt-repository "deb http://pkg.yeti-switch.org/debian/buster unstable main"
28+
sudo add-apt-repository "deb http://deb.debian.org/debian buster main buster non-free"
29+
sudo add-apt-repository "deb http://apt.postgresql.org/pub/repos/apt/ buster-pgdg main"
30+
sudo apt-get install postgresql-16 postgresql-contrib-13 postgresql-16-prefix postgresql-16-pgq3 postgresql-16-pgq-ext postgresql-16-yeti postgresql-16-pllua
31+
sudo apt-get install -t buster-pgdg libpq-dev
3332
```
33+
In addition you need to compile or install from .deb package Yeti PostgreSQL extension `postgresql-16-yeti` https://github.com/yeti-switch/yeti-pg-ext
3434

35-
Without these, `/admin` is public to anyone and the utility navigation (including the logout button) renders empty.
36-
37-
### 2. `app/models/admin_user.rb`
35+
## Preparing yeti-web application
3836

39-
```ruby
40-
class AdminUser < ApplicationRecord
41-
devise :database_authenticatable,
42-
:rememberable,
43-
:omniauthable, omniauth_providers: [:oidc]
37+
Fork and clone yeti-web repository and run:
4438

45-
serialize :oidc_raw_info, coder: JSON
46-
end
39+
```sh
40+
bundle install
4741
```
4842

49-
### 3. `config/initializers/devise.rb`
50-
51-
ActiveAdmin mounts Devise under `/admin`, so OmniAuth's path prefix has to match:
52-
53-
```ruby
54-
config.omniauth_path_prefix = "/admin/auth"
55-
```
43+
Then create `config/database.yml`, example is `config/database.yml.development`. Notice this project uses two databases main "yeti" and second database "cdr"
5644

57-
### 4. `config/initializers/activeadmin_oidc.rb` (generated)
45+
Then create `config/yeti_web.yml`, example is `config/yeti_web.yml.development`.
46+
Then create `config/secrets.yml`, example is `config/secrets.yml.distr`.
5847

59-
Fill in at minimum `issuer`, `client_id`, and an `on_login` hook. Full reference below.
48+
To disable the creation of new versions via paper_trail for some model please fill the array under key `versioning_disable_for_models` in the `config/yeti_web.yml`
6049

61-
## Configuration
50+
Сreate `config/policy_roles.yml`, example is `config/policy_roles.yml.distr` or disable policy feature by changing following lines in `config/yeti_web.yml`:
6251

63-
```ruby
64-
ActiveAdmin::Oidc.configure do |c|
65-
# --- Provider endpoints -----------------------------------------------
66-
c.issuer = ENV.fetch("OIDC_ISSUER")
67-
c.client_id = ENV.fetch("OIDC_CLIENT_ID")
68-
c.client_secret = ENV.fetch("OIDC_CLIENT_SECRET", nil) # blank ⇒ PKCE public client
69-
70-
# --- OIDC scopes ------------------------------------------------------
71-
# c.scope = "openid email profile"
72-
73-
# --- Identity lookup --------------------------------------------------
74-
# Which AdminUser column to match existing rows against, and which
75-
# claim on the id_token/userinfo to read for the lookup.
76-
# c.identity_attribute = :email
77-
# c.identity_claim = :email
78-
79-
# --- AdminUser model resolution ---------------------------------------
80-
# Accepts a String (lazy constant lookup, recommended) or a Class.
81-
# Use when your model is not literally ::AdminUser.
82-
# c.admin_user_class = "Admin::User"
83-
84-
# --- UI copy ----------------------------------------------------------
85-
# c.login_button_label = "Sign in with Corporate SSO"
86-
# c.access_denied_message = "Your account has no permission to access this admin panel."
87-
88-
# --- PKCE override ----------------------------------------------------
89-
# By default PKCE is enabled iff client_secret is blank. Override:
90-
# c.pkce = true
91-
92-
# --- Authorization hook (REQUIRED) ------------------------------------
93-
c.on_login = ->(admin_user, claims) {
94-
# ... see "The on_login hook" below
95-
true
96-
}
97-
end
52+
```yaml
53+
role_policy:
54+
when_no_config: allow
55+
when_no_policy_class: allow
9856
```
9957
100-
### Option reference
58+
And run command to create development database:
10159
102-
| Option | Default | Purpose |
103-
|---|---|---|
104-
| `issuer` | — (required) | OIDC discovery base URL |
105-
| `client_id` | — (required) | IdP client identifier |
106-
| `client_secret` | `nil` | Blank ⇒ PKCE public client |
107-
| `scope` | `"openid email profile"` | Space-separated OIDC scopes |
108-
| `pkce` | auto | `true` when `client_secret` is blank; overridable |
109-
| `identity_attribute` | `:email` | AdminUser column used for lookup/adoption |
110-
| `identity_claim` | `:email` | Claim key read from the id_token/userinfo |
111-
| `admin_user_class` | `"AdminUser"` | String or Class for the host's admin user model |
112-
| `login_button_label` | `"Sign in with SSO"` | Label on the login-page button |
113-
| `access_denied_message` | generic | Flash shown on any denial |
114-
| `on_login` | — (required) | Authorization hook; see below |
60+
```sh
61+
RAILS_ENV=development bundle exec rake db:create db:schema:load db:migrate db:seed
62+
RAILS_ENV=development bundle exec rake custom_seeds[network_prefixes]
63+
```
11564

116-
## The `on_login` hook
65+
You can skip `custom_seeds[network_prefixes]` is you want to use your own network prefixes.
11766

118-
`on_login` is the **only** place authorization lives. The gem handles authentication (the user proved who they are via the IdP); deciding whether that user is allowed into the admin panel — and what they can see once they are in — is the host application's problem. The gem does not ship a role model.
67+
Then start rails server `bundle exec rails s` and login to http://localhost:3000/ with
68+
login `admin` and password `111111`
11969

120-
### Signature
70+
Then prepare test database(do not use db:test:prepare).
12171

122-
```ruby
123-
c.on_login = ->(admin_user, claims) {
124-
# admin_user: an instance of the configured admin_user_class.
125-
# Either a pre-existing row (matched by provider/uid or by
126-
# identity_attribute) or an unsaved new record.
127-
# claims: a Hash of String keys. Contains everything the IdP
128-
# returned in the id_token/userinfo, plus the top-level
129-
# `sub` (copied from the OmniAuth uid) and `email`
130-
# (copied from info.email) for convenience.
131-
# access_token / refresh_token / id_token are NEVER
132-
# present — they are stripped before this hook runs.
133-
#
134-
# Return truthy to allow sign-in.
135-
# Return falsy (false/nil) to deny: the user sees a generic denial
136-
# flash and no AdminUser record is persisted or mutated.
137-
#
138-
# Any mutations you make to admin_user are persisted automatically
139-
# after the hook returns truthy.
140-
#
141-
# Exceptions raised inside the hook are logged at :error via
142-
# ActiveAdmin::Oidc.logger and surface to the user as the same
143-
# generic denial flash — the callback action never 500s.
144-
true
145-
}
72+
```sh
73+
RAILS_ENV=test bundle exec rake db:drop db:create db:schema:load db:migrate db:seed
74+
RAILS_ENV=test bundle exec rake custom_seeds[network_prefixes]
14675
```
14776

148-
### Example A — Zitadel nested project roles claim
77+
This project has CDR-database, configured as cdr
78+
see https://guides.rubyonrails.org/active_record_multiple_databases.html
79+
And all commands should be run explicitly by calling "db:*:cdr" commands.
14980

150-
Zitadel emits roles under the custom claim `urn:zitadel:iam:org:project:roles`, shaped as `{ "role-name" => { "org-id" => "org-name" } }`. Flatten the keys into a string array on the AdminUser.
81+
NOTICE: Test DB needs seeds, actually only PGQ seed.
15182

152-
```ruby
153-
c.on_login = ->(admin_user, claims) {
154-
roles = claims["urn:zitadel:iam:org:project:roles"]&.keys || []
155-
return false if roles.empty?
83+
And run tests:
15684

157-
admin_user.roles = roles
158-
admin_user.name = claims["name"] if claims["name"].present?
159-
true
160-
}
85+
```sh
86+
bundle exec rspec
16187
```
16288

163-
### Example B — department-based gating
89+
## Migrations
16490

165-
```ruby
166-
KNOWN_DEPARTMENTS = %w[ops eng support].freeze
91+
When you run several migrations in a row, you may wish to stop at some point. In this case you should add `stop_step` method to the migration:
16792

168-
c.on_login = ->(admin_user, claims) {
169-
dept = claims["department"]
170-
return false unless KNOWN_DEPARTMENTS.include?(dept)
93+
```ruby
94+
# example /db/migrate/20171105085529_one.rb
95+
def change
96+
# do something
97+
end
17198

172-
admin_user.department = dept
99+
def stop_step
173100
true
174-
}
101+
end
175102
```
176103

177-
### Example C — syncing from a standard `groups` claim (Keycloak-style)
178-
179-
```ruby
180-
ADMIN_GROUP = "admins"
104+
In this case all migrations after this one will no be performed. To continue migration process you should run `rake db:migrate` command again.
181105

182-
c.on_login = ->(admin_user, claims) {
183-
groups = Array(claims["groups"])
184-
return false unless groups.include?(ADMIN_GROUP)
106+
If you do not want to migrate with stops, use env-variable IGNORE_STOPS=true
185107

186-
admin_user.super_admin = groups.include?("super-admins")
187-
true
188-
}
108+
```sh
109+
IGNORE_STOPS=true bundle exec rake db:migrate
189110
```
190111

191-
## Reading additional claims from the callback
112+
## Migrations that insert rows into yeti database
192113

193-
Every key the IdP returns in the id_token or userinfo is passed to `on_login` as part of `claims`. Custom claims work the same as standard ones — just read them by key:
114+
```bash
115+
RAILS_ENV=test bundle exec rake db:create db:schema:load db:seed
116+
RAILS_ENV=test bundle exec rake custom_seeds[network_prefixes]
117+
# create migration inside db/migrations
118+
RAILS_ENV=test bundle exec rake db:migrate
119+
# SCHEMA_NAME - schema of table into which you've inserted row(s)
120+
# YETI_TEST_DB_NAME - yeti test database name on local machine
121+
pg_dump --column-inserts --data-only --schema=SCHEMA_NAME --file=db/seeds/main/SCHEMA_NAME.sql YETI_TEST_DB_NAME
122+
```
194123

195-
```ruby
196-
c.on_login = ->(admin_user, claims) {
197-
admin_user.employee_id = claims["employee_id"]
198-
admin_user.given_name = claims["given_name"]
199-
admin_user.family_name = claims["family_name"]
200-
admin_user.locale = claims["locale"]
201-
admin_user.email_verified = claims["email_verified"]
202-
# Nested / structured claims come through as whatever the IdP sent.
203-
# Zitadel metadata, for instance:
204-
admin_user.tenant_id = claims.dig("urn:zitadel:iam:user:metadata", "tenant_id")
205-
true
206-
}
124+
If you want to use network prefixes from yaml you need to exclude them from db/seeds/main/sys.sql
125+
```bash
126+
pg_dump --column-inserts --data-only --schema=sys --file=db/seeds/main/sys.sql --exclude-table=countries --exclude-table=networks --exclude-table=network_prefixes --exclude-table=network_types YETI_TEST_DB_NAME
207127
```
208128

209-
The full claim hash (minus `access_token` / `refresh_token` / `id_token`) is also stored on the admin user as `oidc_raw_info` — a JSON column created by the install generator. Read it later outside the hook for debugging or for showing the user's profile from the IdP:
129+
## Dump network prefixes
210130

211131
```ruby
212-
AdminUser.last.oidc_raw_info
213-
# => { "sub" => "...", "email" => "...", "groups" => [...], ... }
132+
nt_keys = %w[id name uuid]
133+
network_types = System::NetworkType.order(id: :asc).pluck(*nt_keys).map { |values| Hash[nt_keys.zip(values)] }
134+
File.write('db/network_types.yml', network_types.to_yaml)
135+
network_keys = %w[id name uuid type_id]
136+
networks = System::Network.order(id: :asc).pluck(*network_keys).map { |values| Hash[network_keys.zip(values)] }
137+
File.write('db/networks.yml', networks.to_yaml)
138+
country_keys = %w[id iso2 name]
139+
countries = System::Country.order(id: :asc).pluck(*country_keys).map { |values| Hash[country_keys.zip(values)] }
140+
File.write('db/countries.yml', countries.to_yaml)
141+
np_keys = %w[id number_max_length number_min_length prefix uuid country_id network_id]
142+
network_prefixes = System::NetworkPrefix.order(id: :asc).pluck(*np_keys).map { |values| Hash[np_keys.zip(values)] }
143+
File.write('db/network_prefixes.yml', network_prefixes.to_yaml)
214144
```
215145

216-
## Sign-in flow
217-
218-
* A login button is added to the ActiveAdmin sessions page via a prepended view override — no templates to edit.
219-
* Clicking it POSTs to `/admin/auth/oidc` with a Rails CSRF token. The gem loads `omniauth-rails_csrf_protection` so OmniAuth 2.x delegates its authenticity check to Rails' forgery protection and `button_to` just works.
220-
* After a successful callback the user is signed in and redirected to `/admin` (not the host app's `/`, which may not exist).
221-
* Logout goes through Devise's stock session destroy. No RP-initiated single-logout ping to the IdP — override the destroy action in your host app if you need that.
146+
## Use Docker Postgres for development
222147

223-
## Security notes
148+
For development purpouse it is convinient to use PostgreSQL from Docker image. Here is the instruction how to set it up-and-running:
224149

225-
### Choice of `identity_attribute`
150+
* Install Docker(Ubuntu example)
226151

227-
The `identity_attribute` column is used to adopt existing AdminUser rows on first SSO login — an existing user with that value in that column, and no `provider`/`uid` yet, gets linked to the IdP identity. **Do not** point this at a column the IdP can influence and that is also security-sensitive. Safe choices: `:email`, `:username`, `:employee_id`. Unsafe choices: `:admin`, `:super_admin`, `:password_digest`, `:roles` — anything whose value encodes a permission.
152+
[Install Docker on Ubuntu 18.10](https://www.thecodecampus.de/blog/install-docker-on-ubuntu-18-10/)
228153

229-
### Unique index on the identity column
154+
* Run following commands in terminal from `yeti-web` projects directory
230155

231-
To prevent concurrent first-logins from creating two AdminUser rows for the same person, the column used as `identity_attribute` should have a database-level unique index. For the default `:email` case the standard Devise migration already adds this. If you pick a custom attribute, add the index yourself:
232-
233-
```ruby
234-
add_index :admin_users, :employee_id, unique: true
235-
```
156+
```
157+
sudo docker build -t yeti_postgres -f ci/pg13.Dockerfile .
158+
```
236159

237-
The gem also adds a unique `(provider, uid)` partial index in its own install migration.
160+
* Start the Postgres Server using docker image, with remapped port to 3010 and volume "yetiPgData" to persist data after docker container stops:
238161

239-
### What's filtered from logs
162+
```
163+
sudo docker run -p 3010:5432 --volume yetiPgData:/var/lib/postgresql yeti_postgres
164+
```
240165

241-
The initializer merges `code`, `id_token`, `access_token`, `refresh_token`, `state`, and `nonce` into `Rails.application.config.filter_parameters` so a mid-callback crash can't dump them into production logs. Your own `filter_parameters` entries are preserved.
242-
243-
## Logger
244-
245-
The gem logs internal diagnostics (on_login exceptions, omniauth failures) via `ActiveAdmin::Oidc.logger`. It defaults to `Rails.logger` when Rails is booted, falling back to a null logger otherwise. Override by assigning directly:
246-
247-
```ruby
248-
ActiveAdmin::Oidc.logger = MyStructuredLogger.new
249-
```
166+
* Update `config/database.yml` with
250167

251-
## License
168+
```yml
169+
username: postgres
170+
password:
171+
port: 3010
172+
```
252173
253-
MIT — see [`LICENSE.txt`](LICENSE.txt).
174+
* Initialize database with instructions described in [Contributing, Development setup](#contributing-development-setup) section(db:create, db:schema:load, etc.)

0 commit comments

Comments
 (0)