|
1 | | -# activeadmin-oidc |
| 1 | +# Welcome to YETI |
| 2 | + |
| 3 | + |
| 4 | +[](https://stand-with-ukraine.pp.ua) |
2 | 5 |
|
3 | | -[](https://github.com/activeadmin-plugins/activeadmin-oidc/actions/workflows/ci.yml) |
4 | 6 |
|
5 | | -OpenID Connect single sign-on for [ActiveAdmin](https://activeadmin.info/). |
| 7 | +[](https://stand-with-ukraine.pp.ua) |
6 | 8 |
|
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). |
8 | 9 |
|
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 |
10 | 11 |
|
11 | | -## Installation |
| 12 | +## Ruby |
12 | 13 |
|
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. |
23 | 15 |
|
24 | | -## Host-app setup checklist |
| 16 | +## Postgresql |
25 | 17 |
|
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/ |
27 | 21 |
|
28 | | -### 1. `config/initializers/active_admin.rb` |
| 22 | +You need to install: |
29 | 23 |
|
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 |
33 | 32 | ``` |
| 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 |
34 | 34 |
|
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 |
38 | 36 |
|
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: |
44 | 38 |
|
45 | | - serialize :oidc_raw_info, coder: JSON |
46 | | -end |
| 39 | +```sh |
| 40 | +bundle install |
47 | 41 | ``` |
48 | 42 |
|
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" |
56 | 44 |
|
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`. |
58 | 47 |
|
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` |
60 | 49 |
|
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`: |
62 | 51 |
|
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 |
98 | 56 | ``` |
99 | 57 |
|
100 | | -### Option reference |
| 58 | +And run command to create development database: |
101 | 59 |
|
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 | +``` |
115 | 64 |
|
116 | | -## The `on_login` hook |
| 65 | +You can skip `custom_seeds[network_prefixes]` is you want to use your own network prefixes. |
117 | 66 |
|
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` |
119 | 69 |
|
120 | | -### Signature |
| 70 | +Then prepare test database(do not use db:test:prepare). |
121 | 71 |
|
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] |
146 | 75 | ``` |
147 | 76 |
|
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. |
149 | 80 |
|
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. |
151 | 82 |
|
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: |
156 | 84 |
|
157 | | - admin_user.roles = roles |
158 | | - admin_user.name = claims["name"] if claims["name"].present? |
159 | | - true |
160 | | -} |
| 85 | +```sh |
| 86 | +bundle exec rspec |
161 | 87 | ``` |
162 | 88 |
|
163 | | -### Example B — department-based gating |
| 89 | +## Migrations |
164 | 90 |
|
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: |
167 | 92 |
|
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 |
171 | 98 |
|
172 | | - admin_user.department = dept |
| 99 | +def stop_step |
173 | 100 | true |
174 | | -} |
| 101 | +end |
175 | 102 | ``` |
176 | 103 |
|
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. |
181 | 105 |
|
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 |
185 | 107 |
|
186 | | - admin_user.super_admin = groups.include?("super-admins") |
187 | | - true |
188 | | -} |
| 108 | +```sh |
| 109 | +IGNORE_STOPS=true bundle exec rake db:migrate |
189 | 110 | ``` |
190 | 111 |
|
191 | | -## Reading additional claims from the callback |
| 112 | +## Migrations that insert rows into yeti database |
192 | 113 |
|
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 | +``` |
194 | 123 |
|
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 |
207 | 127 | ``` |
208 | 128 |
|
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 |
210 | 130 |
|
211 | 131 | ```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) |
214 | 144 | ``` |
215 | 145 |
|
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 |
222 | 147 |
|
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: |
224 | 149 |
|
225 | | -### Choice of `identity_attribute` |
| 150 | +* Install Docker(Ubuntu example) |
226 | 151 |
|
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/) |
228 | 153 |
|
229 | | -### Unique index on the identity column |
| 154 | +* Run following commands in terminal from `yeti-web` projects directory |
230 | 155 |
|
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 | + ``` |
236 | 159 |
|
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: |
238 | 161 |
|
239 | | -### What's filtered from logs |
| 162 | + ``` |
| 163 | + sudo docker run -p 3010:5432 --volume yetiPgData:/var/lib/postgresql yeti_postgres |
| 164 | + ``` |
240 | 165 |
|
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 |
250 | 167 |
|
251 | | -## License |
| 168 | + ```yml |
| 169 | + username: postgres |
| 170 | + password: |
| 171 | + port: 3010 |
| 172 | + ``` |
252 | 173 |
|
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