diff --git a/Gemfile.lock b/Gemfile.lock index 4a09f6f..31a56e9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -85,7 +85,7 @@ GEM bindex (0.8.1) bootsnap (1.24.5) msgpack (~> 1.2) - brakeman (8.0.4) + brakeman (8.0.5) racc builder (3.3.0) bundler-audit (0.9.3) @@ -177,7 +177,7 @@ GEM drb (~> 2.0) prism (~> 1.5) msgpack (1.8.0) - net-imap (0.6.4) + net-imap (0.6.4.1) date net-protocol net-pop (0.1.2) @@ -449,7 +449,7 @@ CHECKSUMS bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e bootsnap (1.24.5) sha256=36b677448524d279b470469aabd5dff4a980e3fa4931a0df68da4a500eb1b6c4 - brakeman (8.0.4) sha256=7bf921fa9638544835df9aa7b3e720a9a72c0267f34f92135955edd80d4dcf6f + brakeman (8.0.5) sha256=03735f9690d3fd4b32d66aacbf0a6d15a84266bdd06b32c05c8ecc8f6021d2be builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef @@ -487,7 +487,7 @@ CHECKSUMS mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 - net-imap (0.6.4) sha256=9a5598c67a3022c284d98430ef1d4948e7dbdb62596f61081ea8ca933270a02b + net-imap (0.6.4.1) sha256=29f0360d75a7efd3539f16ac1957dea5c0a51ddeceb348db4553c3120914ea0d net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4e992ef..e76a64b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -32,6 +32,10 @@ def oauth_callback_redirect_uri(slug) # controllers descend from ActionController::API, not this class, so they keep # their own ApiKey/proxy-token auth and are unaffected. before_action :require_login + # A signed-in user must also be approved (active) to use the console. The login + # and pending controllers skip this so pending users can reach the holding page + # and sign out. + before_action :require_active_account private @@ -47,6 +51,45 @@ def require_login redirect_to login_path unless current_user end + # Second gate, after require_login: a disabled user is signed out; a pending + # (not-yet-approved) user is sent to the holding page. Active users pass through. + def require_active_account + return unless current_user + if current_user.disabled? + reset_session + redirect_to login_path, alert: "Your account is disabled." + elsif current_user.pending? + redirect_to pending_path + end + end + + # Guard for admin-only controllers (e.g. user management). Not a global gate. + def require_admin + redirect_to root_path, alert: "That page is restricted to admins." unless current_user&.admin? + end + + # Establishes the console cookie session and sends the user to the right + # post-login page. Password login re-renders for disabled accounts; SSO login + # redirects because it is returning from an external provider. + def sign_in_console_user(user, disabled: :redirect) + if user.disabled? + if disabled == :render + flash.now[:alert] = "Your account is disabled." + return render :new, status: :unprocessable_entity + end + + return redirect_to login_path, alert: "Your account is disabled." + end + + reset_session + session[:user_id] = user.id + if user.active? + redirect_to console_principals_path, notice: "Signed in as #{user.email}." + else + redirect_to pending_path, notice: "Your account is awaiting approval." + end + end + def render_not_found(e) render plain: e.message, status: :not_found end diff --git a/app/controllers/console/users_controller.rb b/app/controllers/console/users_controller.rb new file mode 100644 index 0000000..d17e1ae --- /dev/null +++ b/app/controllers/console/users_controller.rb @@ -0,0 +1,41 @@ +module Console + # Operator (console user) management: approve pending sign-ins, disable access, + # or promote a user to admin. Admin-only -- the gate is require_admin, layered on + # top of the app-wide require_login/require_active_account gates. + class UsersController < ApplicationController + layout "console" + + before_action :require_admin + before_action :set_user, only: %i[approve disable promote] + + def index + @pending = User.pending.includes(:user_identities).order(:created_at) + @users = User.where.not(status: "pending").includes(:user_identities).order(admin: :desc, email: :asc) + end + + def approve + @user.approve!(by: current_user) + redirect_to console_users_path, notice: "Approved #{@user.email}." + end + + def disable + if @user == current_user + return redirect_to console_users_path, alert: "You can't disable your own account." + end + @user.update!(status: :disabled) + redirect_to console_users_path, notice: "Disabled #{@user.email}." + end + + def promote + # Promoting also activates: an admin must be able to use the console. + @user.update!(admin: true, status: :active) + redirect_to console_users_path, notice: "#{@user.email} is now an admin." + end + + private + + def set_user + @user = User.find_by_oid!(params[:id]) + end + end +end diff --git a/app/controllers/oauth/flows_controller.rb b/app/controllers/oauth/flows_controller.rb index d3da7c4..27f61a6 100644 --- a/app/controllers/oauth/flows_controller.rb +++ b/app/controllers/oauth/flows_controller.rb @@ -23,6 +23,7 @@ class FlowsController < ApplicationController layout "auth" skip_before_action :require_login + skip_before_action :require_active_account # The message_verifier purpose binding the signed state to this flow, the # state/cookie lifetime, and the encrypted cookie that ties a callback back to diff --git a/app/controllers/session_oauth_controller.rb b/app/controllers/session_oauth_controller.rb new file mode 100644 index 0000000..56ae5ad --- /dev/null +++ b/app/controllers/session_oauth_controller.rb @@ -0,0 +1,136 @@ +require "base64" +require "digest" +require "uri" + +# Console SSO login, keyed by provider: /auth/:provider/start sends an operator to +# the IdP, and /auth/:provider/callback turns the returned code into a signed-in +# User. Structurally mirrors Oauth::FlowsController (signed state, PKCE, an +# encrypted flow cookie binding the callback to the browser that started it), but +# it produces a console session instead of a BrokerCredential. +# +# Provisioning (find_or_provision_user): a returning identity matches by +# (provider, subject); a new identity whose verified email matches an existing +# user links to that user; otherwise a new user is created -- active + admin when +# the email is on the bootstrap allowlist, pending otherwise. +# +# SECURITY: never logs codes, tokens, or response bodies -- only provider keys and +# error codes, like the Broker/Oauth subsystem. Account linking is gated on +# email_verified so an unverified IdP email can't take over an existing account. +class SessionOauthController < ApplicationController + layout "auth" + + # The login form, the IdP redirect, and the callback must all work signed out. + skip_before_action :require_login + skip_before_action :require_active_account + + STATE_PURPOSE = :console_login_flow + FLOW_TTL = 10.minutes + FLOW_COOKIE = :console_login_flow + + # Tests swap in an AuthorizationCodeClient built around an http double, mirroring + # Oauth::FlowsController. + class_attribute :exchange_client_factory, default: -> { Broker::AuthorizationCodeClient.new } + + before_action :set_provider + + # GET /auth/:provider/start + def start + nonce = SecureRandom.urlsafe_base64(32) + code_verifier = SecureRandom.urlsafe_base64(64) + + state = Rails.application.message_verifier(STATE_PURPOSE).generate( + { "provider" => @key, "nonce" => nonce }, + purpose: STATE_PURPOSE, expires_in: FLOW_TTL + ) + + # :lax is required -- the callback arrives via a top-level cross-site redirect + # from the IdP, which Lax permits for GET. + cookies.encrypted[FLOW_COOKIE] = { + value: { "nonce" => nonce, "code_verifier" => code_verifier }.to_json, + expires: FLOW_TTL.from_now, httponly: true, same_site: :lax + } + + redirect_to authorization_url(state, code_verifier), allow_other_host: true + end + + # GET /auth/:provider/callback?code=&state= (or ?error=) + def callback + state = Rails.application.message_verifier(STATE_PURPOSE).verified(params[:state], purpose: STATE_PURPOSE) + return invalid_flow if state.nil? || state["provider"] != @key + + flow = read_and_clear_flow_cookie + return invalid_flow if flow.nil? || flow["nonce"] != state["nonce"] + + if params[:error].present? + return redirect_to login_path, alert: "Sign in was canceled." + end + + result = exchange_code(params[:code], flow["code_verifier"]) + identity = @provider.identity_from(result, client_id: ConsoleAuth.client_id(@key)) + sign_in_console_user(User.link_or_provision(provider: @key, identity: identity)) + rescue Broker::ExchangeError => e + Rails.logger.error { "console login exchange failed (#{@key}): #{e.reason}" } + redirect_to login_path, alert: "Sign in failed. Please try again." + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error { "console login provisioning failed: #{e.record.errors.full_messages.to_sentence}" } + redirect_to login_path, alert: "Sign in failed while setting up your account." + end + + private + + # Resolves the provider strategy, rejecting unknown or unconfigured providers + # (no client credentials => no button => no flow). + def set_provider + @key = params[:provider].to_s + @provider = ConsoleAuth.configured?(@key) ? Login::Providers.fetch(@key) : nil + redirect_to login_path, alert: "That sign-in method is not available." if @provider.nil? + end + + def authorization_url(state, code_verifier) + challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + query = { + "client_id" => ConsoleAuth.client_id(@key), + "redirect_uri" => callback_redirect_uri, + "response_type" => "code", + "scope" => @provider.scopes.join(" "), + "state" => state, + "code_challenge" => challenge, + "code_challenge_method" => "S256" + }.merge(@provider.extra_authorization_params) + + uri = URI.parse(@provider.authorization_endpoint) + uri.query = URI.encode_www_form(query) + uri.to_s + end + + def exchange_code(code, code_verifier) + exchange_client_factory.call.exchange( + token_endpoint: @provider.token_endpoint, + client_id: ConsoleAuth.client_id(@key), + client_secret: ConsoleAuth.client_secret(@key), + code: code.to_s, + redirect_uri: callback_redirect_uri, + code_verifier: code_verifier.to_s, + # Login requests no offline access, so the IdP returns no refresh token. + require_refresh_token: false + ) + end + + # The callback redirect URI registered with the IdP: "/auth//callback". + def callback_redirect_uri + URI.join(public_base_url, "/auth/#{@key}/callback").to_s + end + + def read_and_clear_flow_cookie + raw = cookies.encrypted[FLOW_COOKIE] + cookies.delete(FLOW_COOKIE) + return nil if raw.blank? + JSON.parse(raw) + rescue JSON::ParserError + nil + end + + def invalid_flow + redirect_to login_path, alert: "This sign-in link is invalid or expired. Start again." + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index e51db19..1db9fb8 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -9,21 +9,28 @@ class SessionsController < ApplicationController # app-wide require_login gate. (logout keeps the gate: it's a no-op when # there's no session.) skip_before_action :require_login, only: %i[new create] + # None of these enforce approval: the login form is anonymous, and pending users + # must still reach the holding page and be able to sign out. + skip_before_action :require_active_account def new - redirect_to console_principals_path if current_user + redirect_to console_principals_path if current_user&.active? + end + + # Holding page for a signed-in but not-yet-approved user. Active users have no + # reason to be here, so send them to the console. + def pending + redirect_to console_principals_path if current_user&.active? end def create user = User.find_by(email: params[:email].to_s.strip.downcase) - if user&.authenticate(params[:password]) - reset_session - session[:user_id] = user.id - redirect_to console_principals_path, notice: "Signed in as #{user.email}." - else + unless user&.authenticate(params[:password]) flash.now[:alert] = "Invalid email or password." - render :new, status: :unprocessable_entity + return render :new, status: :unprocessable_entity end + + sign_in_console_user(user, disabled: :render) end def destroy diff --git a/app/helpers/console/users_helper.rb b/app/helpers/console/users_helper.rb new file mode 100644 index 0000000..9ae3098 --- /dev/null +++ b/app/helpers/console/users_helper.rb @@ -0,0 +1,31 @@ +module Console + # View helpers for the operator (console user) management screen. + module UsersHelper + STATUS_CHIP_CLASSES = { + "active" => "bg-emerald-500/10 text-emerald-300 ring-emerald-500/20", + "pending" => "bg-amber-500/10 text-amber-300 ring-amber-500/20", + "disabled" => "bg-zinc-500/10 text-zinc-400 ring-zinc-500/20" + }.freeze + + IDP_CHIP_CLASSES = "bg-ink-700/60 text-zinc-300 ring-ink-500".freeze + + # A colored pill for a user's account status. + def user_status_badge(user) + chip(user.status, STATUS_CHIP_CLASSES.fetch(user.status, STATUS_CHIP_CLASSES["disabled"])) + end + + # One pill per linked identity provider, or a single "password" pill when the + # account has no SSO identity. All share the same neutral IdP styling. + def user_idp_chips(user) + labels = user.user_identities.map(&:provider).uniq.sort + labels = %w[password] if labels.empty? + tag.div(safe_join(labels.map { |label| chip(label.capitalize, IDP_CHIP_CLASSES) }), class: "flex flex-wrap gap-1") + end + + private + + def chip(text, classes) + tag.span(text, class: "rounded px-1.5 py-0.5 text-xs ring-1 #{classes}") + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 21a1f47..83e8c31 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,9 +1,19 @@ class User < ApplicationRecord oid_prefix "usr" - has_secure_password + # validations: false because SSO-only users have no password. The password + # length rule below still applies to anyone who does set one (password login is + # kept as a break-glass fallback). + has_secure_password validations: false has_many :api_keys, dependent: :destroy + has_many :user_identities, dependent: :destroy + belongs_to :approved_by, class_name: "User", optional: true + + # pending: signed in via SSO but not yet approved -- cannot use the console. + # active: approved operator. disabled: access revoked. + enum :status, { pending: "pending", active: "active", disabled: "disabled" }, + default: :pending, validate: true normalizes :email, with: ->(e) { e.strip.downcase } @@ -12,4 +22,50 @@ class User < ApplicationRecord uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :password, length: { minimum: 12 }, allow_nil: true + + # Marks a pending user active, recording who approved them and when. + def approve!(by:) + update!(status: :active, approved_at: Time.current, approved_by: by) + end + + # Resolves the console user behind a verified SSO identity, creating or linking + # as needed, and (re)caches the identity's email/name. A returning login matches + # by the stable (provider, subject). A new identity links to an existing user + # only when the IdP-verified email matches -- an unverified email must never + # adopt an account -- otherwise a new user is created: active + admin when the + # email is on the bootstrap allowlist, pending otherwise. +identity+ is the + # provider strategy's { subject:, email:, email_verified:, name: } hash. + def self.link_or_provision(provider:, identity:) + transaction do + if (existing = UserIdentity.find_by(provider: provider, subject: identity[:subject])) + existing.update!(email: identity[:email], email_verified: identity[:email_verified]) + user = existing.user + user.update!(name: identity[:name]) if identity[:name].present? && user.name.blank? + next user + end + + user = linkable_user(identity) || create!(provisioned_attributes(identity)) + user.user_identities.create!( + provider: provider, subject: identity[:subject], + email: identity[:email], email_verified: identity[:email_verified] + ) + user + end + end + + # An existing user this identity may attach to: only when the IdP marked the + # email verified (an unverified email must not adopt an existing account). + def self.linkable_user(identity) + return nil unless identity[:email_verified] && identity[:email].present? + find_by(email: identity[:email].strip.downcase) + end + private_class_method :linkable_user + + # Attributes for a brand-new SSO user: active + admin when bootstrap-allowlisted + # by a verified IdP email, pending otherwise. + def self.provisioned_attributes(identity) + admin = identity[:email_verified] == true && ConsoleAuth.bootstrap_admin?(identity[:email]) + { email: identity[:email], name: identity[:name], status: admin ? :active : :pending, admin: admin } + end + private_class_method :provisioned_attributes end diff --git a/app/models/user_identity.rb b/app/models/user_identity.rb new file mode 100644 index 0000000..624f139 --- /dev/null +++ b/app/models/user_identity.rb @@ -0,0 +1,16 @@ +# A linked SSO identity for a console User: the provider plus the IdP's stable +# subject. A returning sign-in is matched by (provider, subject), which never +# changes, rather than by email, which can. email/email_verified are cached from +# the last sign-in for display and to gate email-based account linking. +class UserIdentity < ApplicationRecord + oid_prefix "usid" + + belongs_to :user + + PROVIDERS = %w[google slack].freeze + + normalizes :email, with: ->(e) { e.to_s.strip.downcase.presence } + + validates :provider, presence: true, inclusion: { in: PROVIDERS } + validates :subject, presence: true, uniqueness: { scope: :provider } +end diff --git a/app/views/console/users/index.html.erb b/app/views/console/users/index.html.erb new file mode 100644 index 0000000..f93d125 --- /dev/null +++ b/app/views/console/users/index.html.erb @@ -0,0 +1,73 @@ +<% content_for :title, "Users · Iron Control" %> + +
+

Users

+

Operator accounts. Approve pending sign-ins, promote admins, or disable access.

+
+ +<% if @pending.any? %> +

<%= pluralize(@pending.size, "pending approval") %>

+
+ + + <% @pending.each do |user| %> + + + + + + + <% end %> + +
+
<%= user.email %>
+ <% if user.name.present? %>
<%= user.name %>
<% end %> +
<%= user_idp_chips(user) %>requested <%= time_ago_in_words(user.created_at) %> ago + <%= button_to "Approve", approve_console_user_path(user.oid), method: :post, + class: "cursor-pointer rounded border border-emerald-500/40 bg-emerald-500/10 px-3 py-1.5 text-emerald-300 transition-colors hover:bg-emerald-500/20" %> +
+
+<% end %> + +
+ + + + + + + + + + + + <% if @users.empty? %> + + <% end %> + <% @users.each do |user| %> + + + + + + + + <% end %> + +
EmailStatusRoleIdPs
No approved users yet.
+
<%= user.email %><% if user == current_user %>(you)<% end %>
+ <% if user.name.present? %>
<%= user.name %>
<% end %> +
<%= user_status_badge(user) %><%= user.admin? ? "admin" : "operator" %><%= user_idp_chips(user) %> +
+ <% unless user.admin? %> + <%= button_to "Make admin", promote_console_user_path(user.oid), method: :post, + class: "cursor-pointer rounded border border-ink-600 px-3 py-1.5 text-zinc-400 transition-colors hover:border-iron-500/40 hover:text-iron-300" %> + <% end %> + <% if user.active? && user != current_user %> + <%= button_to "Disable", disable_console_user_path(user.oid), method: :post, + form: { data: { turbo_confirm: "Disable #{user.email}?" } }, + class: "cursor-pointer rounded border border-ink-600 px-3 py-1.5 text-zinc-400 transition-colors hover:border-red-500/40 hover:text-red-300" %> + <% end %> +
+
+
diff --git a/app/views/layouts/console.html.erb b/app/views/layouts/console.html.erb index 27790bf..92c5767 100644 --- a/app/views/layouts/console.html.erb +++ b/app/views/layouts/console.html.erb @@ -36,6 +36,7 @@
<% end %> + <% providers = ConsoleAuth.providers %> + <% if providers.any? %> +
+ <% providers.each do |provider| %> + <%= link_to auth_start_path(provider: provider), + class: "flex w-full items-center justify-center rounded border border-ink-600 bg-ink-800/60 px-4 py-2 text-sm text-zinc-200 transition-colors hover:border-iron-500/40 hover:text-iron-200" do %> + Continue with <%= provider.capitalize %> + <% end %> + <% end %> +
+ +
+ + or sign in with email + +
+ <% end %> + <%= form_with url: login_path, method: :post do |f| %>
diff --git a/app/views/sessions/pending.html.erb b/app/views/sessions/pending.html.erb new file mode 100644 index 0000000..bdd998e --- /dev/null +++ b/app/views/sessions/pending.html.erb @@ -0,0 +1,19 @@ +<% content_for :title, "Awaiting Approval · Iron Control" %> + +
+

iron-control

+

Operator console.

+
+ +
+

Awaiting approval

+

+ You're signed in as <%= current_user.email %>, but your account + is pending approval by an administrator. You'll have access once it's approved. +

+ +
+ <%= button_to "Sign out", logout_path, method: :delete, + class: "w-full cursor-pointer rounded border border-ink-600 px-4 py-2 text-sm text-zinc-400 transition-colors hover:border-iron-500/40 hover:text-iron-300" %> +
+
diff --git a/config/initializers/bootstrap.rb b/config/initializers/bootstrap.rb index 1bac95f..a7dcb1c 100644 --- a/config/initializers/bootstrap.rb +++ b/config/initializers/bootstrap.rb @@ -3,8 +3,16 @@ next if ENV["IRON_CONTROL_INITIAL_USER_EMAIL"].to_s.strip.empty? begin + # after_initialize fires on every boot, including the environment load that + # `db:prepare` performs *before* it applies migrations. Skip until the schema + # is current so we never load a model (e.g. User's status enum) whose column a + # pending migration still has to add. The server's own boot, which happens + # after db:prepare has migrated, runs the bootstrap cleanly. + next if ActiveRecord::Base.connection_pool.migration_context.needs_migration? + Iron::Bootstrap.run! - rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished - # DB not provisioned yet (e.g. running `db:create`); skip silently. + rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished, ActiveRecord::StatementInvalid + # DB not provisioned, not reachable, or its schema_migrations table not + # created yet (e.g. during `db:create`). A later boot will bootstrap. end end diff --git a/config/routes.rb b/config/routes.rb index a77f0b9..301e0a3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -13,6 +13,14 @@ get "login", to: "sessions#new", as: :login post "login", to: "sessions#create" delete "logout", to: "sessions#destroy", as: :logout + # Holding page for a signed-in user whose account is still pending approval. + get "pending", to: "sessions#pending", as: :pending + + # Console SSO login, keyed by provider (/auth/google/start). Deliberately + # unauthenticated; distinct /auth/* prefix avoids colliding with the broker's + # /oauth/:slug/* consent flow below. + get "auth/:provider/start", to: "session_oauth#start", as: :auth_start + get "auth/:provider/callback", to: "session_oauth#callback", as: :auth_callback # Operator console (server-rendered HTML UI). root "console#principals" @@ -44,6 +52,17 @@ end get "console/oauth_apps/:id", to: "console#oauth_app", as: :console_oauth_app + # Operator (console user) management. Admin-only; pending users are approved here. + namespace :console do + resources :users, only: %i[index] do + member do + post :approve + post :disable + post :promote + end + end + end + namespace :api do namespace :v1 do # Each secret type is addressable by opaque oid (member routes) or by an diff --git a/db/migrate/20260612100000_add_sso_fields_to_users.rb b/db/migrate/20260612100000_add_sso_fields_to_users.rb new file mode 100644 index 0000000..a363f2d --- /dev/null +++ b/db/migrate/20260612100000_add_sso_fields_to_users.rb @@ -0,0 +1,28 @@ +class AddSsoFieldsToUsers < ActiveRecord::Migration[8.1] + # Adds the console-SSO + approval columns. New users land `pending` and cannot + # use the console until an admin approves them; `admin` gates the approval + # screen. `name` carries the IdP display name. password_digest becomes nullable + # because SSO-only users have no password. + def up + add_column :users, :status, :string, null: false, default: "pending" + add_column :users, :admin, :boolean, null: false, default: false + add_column :users, :name, :string + add_column :users, :approved_at, :datetime + add_reference :users, :approved_by, foreign_key: { to_table: :users } + + change_column_null :users, :password_digest, true + + # Existing operators predate SSO + approval and are the current admins; keep + # them working rather than locking everyone out behind the new gate. + execute "UPDATE users SET status = 'active', admin = true" + end + + def down + remove_reference :users, :approved_by, foreign_key: { to_table: :users } + remove_column :users, :approved_at + remove_column :users, :name + remove_column :users, :admin + remove_column :users, :status + change_column_null :users, :password_digest, false + end +end diff --git a/db/migrate/20260612100100_create_user_identities.rb b/db/migrate/20260612100100_create_user_identities.rb new file mode 100644 index 0000000..fb39ba9 --- /dev/null +++ b/db/migrate/20260612100100_create_user_identities.rb @@ -0,0 +1,17 @@ +class CreateUserIdentities < ActiveRecord::Migration[8.1] + # A linked SSO identity for a console user. One user can have several (e.g. both + # Google and Slack); a returning user is matched by the stable (provider, + # subject) pair rather than by their mutable email. + def change + create_table :user_identities do |t| + t.references :user, null: false, foreign_key: true + t.string :provider, null: false + t.string :subject, null: false + t.string :email + t.boolean :email_verified, null: false, default: false + + t.timestamps + end + add_index :user_identities, [ :provider, :subject ], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 84a45dc..f5f13c2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_11_130000) do +ActiveRecord::Schema[8.1].define(version: 2026_06_12_100100) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -319,11 +319,29 @@ t.index ["namespace", "foreign_id"], name: "index_static_secrets_on_namespace_and_foreign_id", unique: true end + create_table "user_identities", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email" + t.boolean "email_verified", default: false, null: false + t.string "provider", null: false + t.string "subject", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["provider", "subject"], name: "index_user_identities_on_provider_and_subject", unique: true + t.index ["user_id"], name: "index_user_identities_on_user_id" + end + create_table "users", force: :cascade do |t| + t.boolean "admin", default: false, null: false + t.datetime "approved_at" + t.bigint "approved_by_id" t.datetime "created_at", null: false t.string "email", null: false - t.string "password_digest", null: false + t.string "name" + t.string "password_digest" + t.string "status", default: "pending", null: false t.datetime "updated_at", null: false + t.index ["approved_by_id"], name: "index_users_on_approved_by_id" t.index ["email"], name: "index_users_on_email", unique: true end @@ -362,4 +380,6 @@ add_foreign_key "secret_sources", "pg_dsn_secrets" add_foreign_key "secret_sources", "static_secrets" add_foreign_key "static_secrets", "users", column: "created_by_id" + add_foreign_key "user_identities", "users" + add_foreign_key "users", "users", column: "approved_by_id" end diff --git a/lib/broker/authorization_code_client.rb b/lib/broker/authorization_code_client.rb index 64b7f83..e5b049d 100644 --- a/lib/broker/authorization_code_client.rb +++ b/lib/broker/authorization_code_client.rb @@ -33,11 +33,15 @@ def initialize(http: nil) end # Exchanges an authorization code for tokens. Raises Broker::ExchangeError on - # any failure (non-2xx, unparseable body, empty access_token, or a missing - # refresh_token -- the last being a misconfiguration given Google's - # access_type=offline + prompt=consent always return one). + # any failure (non-2xx, unparseable body, empty access_token, or -- when + # require_refresh_token is true -- a missing refresh_token). + # + # require_refresh_token defaults to true for the broker consent flow, where + # access_type=offline + prompt=consent always return one and its absence means + # the app is misconfigured. The console-login flow passes false: it requests no + # offline access and only needs the id_token to identify the operator. def exchange(token_endpoint:, client_id:, client_secret:, code:, redirect_uri:, - code_verifier:, timeout: DEFAULT_TIMEOUT) + code_verifier:, timeout: DEFAULT_TIMEOUT, require_refresh_token: true) raise ArgumentError, "token endpoint is required" if token_endpoint.blank? raise ArgumentError, "client_id is required" if client_id.blank? raise ArgumentError, "code is required" if code.blank? @@ -57,7 +61,7 @@ def exchange(token_endpoint:, client_id:, client_secret:, code:, redirect_uri:, classify_error(response.status, response.body) if response.status / 100 != 2 - parse_success(response) + parse_success(response, require_refresh_token: require_refresh_token) end private @@ -84,7 +88,7 @@ def perform(url, form, timeout) raise ExchangeError.new("token endpoint request failed: #{e.class}", stage: "network") end - def parse_success(response) + def parse_success(response, require_refresh_token:) parsed = JSON.parse(response.body) access_token = parsed["access_token"] if access_token.blank? @@ -93,9 +97,10 @@ def parse_success(response) end refresh_token = parsed["refresh_token"] - if refresh_token.blank? + if require_refresh_token && refresh_token.blank? # With access_type=offline + prompt=consent a refresh token is always - # returned; its absence means the app is misconfigured at the IdP. + # returned; its absence means the app is misconfigured at the IdP. The + # login flow opts out of this check (it requests no offline access). raise ExchangeError.new("token endpoint returned no refresh_token", stage: "oauth", code: "missing_refresh_token", status: response.status) end diff --git a/lib/console_auth.rb b/lib/console_auth.rb new file mode 100644 index 0000000..7b76366 --- /dev/null +++ b/lib/console_auth.rb @@ -0,0 +1,57 @@ +# Configuration for console SSO login. Unlike OauthApp (a DB-managed integration +# the broker mints credentials for), the login client is infrastructure: its +# client_id/client_secret and the bootstrap-admin allowlist come from the +# environment (or Rails credentials as a fallback), not a table. +# +# Per provider, looks up: +# IRON_CONTROL__CLIENT_ID / _CLIENT_SECRET (ENV) +# credentials.console_auth..client_id/secret (fallback) +# A provider is offered on the login page only when both are present. +# +# Bootstrap admins are matched by email and become active + admin on first login +# (the first admin needs no existing approver): +# IRON_CONTROL_BOOTSTRAP_ADMINS="me@acme.com, you@acme.com" (ENV) +# credentials.console_auth.bootstrap_admins (fallback: string or array) +module ConsoleAuth + # The providers a Login::Providers strategy exists for. A provider must also be + # `configured?` to actually appear on the login page. + SUPPORTED = %w[google slack].freeze + + module_function + + # Configured + supported provider keys, for the login page buttons. + def providers + SUPPORTED.select { |p| configured?(p) } + end + + def configured?(provider) + SUPPORTED.include?(provider.to_s) && client_id(provider).present? && client_secret(provider).present? + end + + def client_id(provider) = setting(provider, "client_id") + def client_secret(provider) = setting(provider, "client_secret") + + def bootstrap_admin?(email) + normalized = email.to_s.strip.downcase + return false if normalized.empty? + bootstrap_admins.include?(normalized) + end + + def bootstrap_admins + raw = ENV["IRON_CONTROL_BOOTSTRAP_ADMINS"].presence || credentials_dig(:bootstrap_admins) + list = raw.is_a?(Array) ? raw : raw.to_s.split(/[,\s]+/) + list.map { |e| e.to_s.strip.downcase }.reject(&:empty?).uniq + end + + # ENV first (IRON_CONTROL_GOOGLE_CLIENT_ID), then credentials + # (console_auth.google.client_id). + def setting(provider, field) + env = ENV["IRON_CONTROL_#{provider.to_s.upcase}_#{field.upcase}"].presence + return env if env + credentials_dig(provider.to_sym, field.to_sym) + end + + def credentials_dig(*path) + Rails.application.credentials.dig(:console_auth, *path) + end +end diff --git a/lib/iron/bootstrap.rb b/lib/iron/bootstrap.rb index f17ea66..517b717 100644 --- a/lib/iron/bootstrap.rb +++ b/lib/iron/bootstrap.rb @@ -23,7 +23,9 @@ def run!(logger: Rails.logger) ActiveRecord::Base.connection.execute("SELECT pg_advisory_xact_lock(#{ADVISORY_LOCK_KEY})") return if User.exists? - user = User.create!(email: email, password: password) + # The initial operator predates any approver, so it is created active and + # admin -- it is the account that approves everyone else. + user = User.create!(email: email, password: password, status: "active", admin: true) api_key = ApiKey.new(user: user, name: "bootstrap") unless supplied_token.empty? diff --git a/lib/login/id_token.rb b/lib/login/id_token.rb new file mode 100644 index 0000000..9dff618 --- /dev/null +++ b/lib/login/id_token.rb @@ -0,0 +1,59 @@ +require "base64" +require "json" + +module Login + # Shared OIDC id_token handling for the console-login provider strategies. + # + # SECURITY: like Oauth::Providers::Google, the id_token came directly from the + # provider's token endpoint over TLS, which OIDC Core 3.1.3.7.6 accepts without + # a separate signature check. We still verify aud == client_id and iss against + # the provider's known issuers. Nothing here logs token material. + module IdToken + module_function + + # Returns { subject:, email:, email_verified:, name: } from a code-exchange + # result's id_token. Raises Broker::ExchangeError (the same error the exchange + # itself raises, so the controller has one rescue) on any problem. + def identity(id_token, client_id:, valid_issuers:) + if id_token.blank? + raise Broker::ExchangeError.new("token response carried no id_token", + stage: "oauth", code: "missing_id_token") + end + + claims = decode_claims(id_token) + + unless claims["aud"] == client_id + raise Broker::ExchangeError.new("id_token aud did not match client_id", + stage: "oauth", code: "id_token_aud_mismatch") + end + unless valid_issuers.include?(claims["iss"]) + raise Broker::ExchangeError.new("id_token iss was not an expected issuer", + stage: "oauth", code: "id_token_iss_invalid") + end + + subject = claims["sub"] + if subject.blank? + raise Broker::ExchangeError.new("id_token carried no sub", + stage: "oauth", code: "id_token_missing_sub") + end + + { + subject: subject, + email: claims["email"], + # Absent/false email_verified blocks email-based account linking. + email_verified: claims["email_verified"] == true, + name: claims["name"].presence + } + end + + # Decodes the JWT payload (second segment), tolerating the unpadded base64url + # JWTs use. No signature verification -- see the module note. + def decode_claims(id_token) + seg = id_token.split(".")[1].to_s + seg += "=" * ((4 - seg.length % 4) % 4) + JSON.parse(Base64.urlsafe_decode64(seg)) + rescue ArgumentError, JSON::ParserError + raise Broker::ExchangeError.new("id_token payload did not decode", stage: "parse") + end + end +end diff --git a/lib/login/providers.rb b/lib/login/providers.rb new file mode 100644 index 0000000..77f7d16 --- /dev/null +++ b/lib/login/providers.rb @@ -0,0 +1,16 @@ +module Login + # Registry of console-login provider strategies. A strategy owns the + # IdP-specific parts of the login flow (endpoints, scopes, id_token identity + # extraction); state signing, PKCE, the code exchange, and user provisioning + # are provider-agnostic and live in SessionOauthController. + module Providers + def self.registry + @registry ||= { Google::KEY => Google.new, Slack::KEY => Slack.new }.freeze + end + + # The strategy for +key+, or nil for an unknown provider. + def self.fetch(key) = registry[key] + + def self.keys = registry.keys + end +end diff --git a/lib/login/providers/google.rb b/lib/login/providers/google.rb new file mode 100644 index 0000000..41527fa --- /dev/null +++ b/lib/login/providers/google.rb @@ -0,0 +1,25 @@ +module Login + module Providers + # Google "Sign in with Google" strategy for console login. Unlike the broker's + # Oauth::Providers::Google it requests no offline access (login needs only a + # one-shot identity, never a refresh token), so it has no extra authorization + # params and a minimal openid/email/profile scope set. + class Google + KEY = "google" + AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth" + TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" + SCOPES = %w[openid email profile].freeze + VALID_ISSUERS = %w[https://accounts.google.com accounts.google.com].freeze + + def key = KEY + def authorization_endpoint = AUTHORIZATION_ENDPOINT + def token_endpoint = TOKEN_ENDPOINT + def scopes = SCOPES + def extra_authorization_params = {} + + def identity_from(result, client_id:) + Login::IdToken.identity(result.id_token, client_id: client_id, valid_issuers: VALID_ISSUERS) + end + end + end +end diff --git a/lib/login/providers/slack.rb b/lib/login/providers/slack.rb new file mode 100644 index 0000000..2e49cec --- /dev/null +++ b/lib/login/providers/slack.rb @@ -0,0 +1,24 @@ +module Login + module Providers + # Slack "Sign in with Slack" (OpenID Connect) strategy for console login. + # Slack's OIDC endpoints differ from its regular OAuth ones; the token + # endpoint returns an id_token carrying the account identity. + class Slack + KEY = "slack" + AUTHORIZATION_ENDPOINT = "https://slack.com/openid/connect/authorize" + TOKEN_ENDPOINT = "https://slack.com/api/openid.connect.token" + SCOPES = %w[openid email profile].freeze + VALID_ISSUERS = %w[https://slack.com].freeze + + def key = KEY + def authorization_endpoint = AUTHORIZATION_ENDPOINT + def token_endpoint = TOKEN_ENDPOINT + def scopes = SCOPES + def extra_authorization_params = {} + + def identity_from(result, client_id:) + Login::IdToken.identity(result.id_token, client_id: client_id, valid_issuers: VALID_ISSUERS) + end + end + end +end diff --git a/lib/tasks/iron.rake b/lib/tasks/iron.rake index a1dcce2..904e1fd 100644 --- a/lib/tasks/iron.rake +++ b/lib/tasks/iron.rake @@ -69,6 +69,11 @@ module IronTasks end namespace :iron do + desc "Create the initial user + API key from IRON_CONTROL_INITIAL_* env vars (no-op if unset or a user already exists)." + task bootstrap: :environment do + Iron::Bootstrap.run! + end + namespace :principal do desc "Create a principal. Env: [NAME], [FOREIGN_ID], [NAMESPACE=default], [LABELS=k=v,..]" task add: :environment do diff --git a/test/controllers/console/users_controller_test.rb b/test/controllers/console/users_controller_test.rb new file mode 100644 index 0000000..d4bcfc6 --- /dev/null +++ b/test/controllers/console/users_controller_test.rb @@ -0,0 +1,83 @@ +require "test_helper" + +module Console + # Covers the admin-only operator management screen: who can reach it, and the + # approve/disable/promote state transitions (including the self-disable guard). + class UsersControllerTest < ActionDispatch::IntegrationTest + def sign_in(user) + post login_url, params: { email: user.email, password: "password123456" } + end + + test "redirects to login when signed out" do + get console_users_url + assert_redirected_to login_path + end + + test "an active non-admin is forbidden" do + sign_in users(:member_user) + get console_users_url + assert_redirected_to root_path + assert_equal "That page is restricted to admins.", flash[:alert] + end + + test "an admin sees the index with pending users listed" do + sign_in users(:acme_admin) + get console_users_url + assert_response :ok + assert_select "td", /pending@acme.example/ + end + + test "the index shows IdP chips for linked identities and a password chip otherwise" do + sign_in users(:acme_admin) + get console_users_url + assert_select "span", text: "Google" # acme_admin is linked via Google + assert_select "span", text: "Slack" # pending_user is linked via Slack + assert_select "span", text: "Password" # member_user has no linked identity + end + + test "approve activates a pending user and records the approver" do + admin = users(:acme_admin) + sign_in admin + pending = users(:pending_user) + post approve_console_user_url(pending.oid) + assert_redirected_to console_users_path + assert pending.reload.active? + assert_equal admin, pending.approved_by + end + + test "disable revokes an active user" do + sign_in users(:acme_admin) + target = users(:member_user) + post disable_console_user_url(target.oid) + assert_redirected_to console_users_path + assert target.reload.disabled? + end + + test "an admin cannot disable their own account" do + admin = users(:acme_admin) + sign_in admin + post disable_console_user_url(admin.oid) + assert_redirected_to console_users_path + assert_equal "You can't disable your own account.", flash[:alert] + assert admin.reload.active? + end + + test "promote makes a user an active admin" do + sign_in users(:acme_admin) + target = users(:pending_user) + post promote_console_user_url(target.oid) + assert_redirected_to console_users_path + target.reload + assert target.admin? + assert target.active? + end + + test "a non-admin cannot perform actions" do + sign_in users(:member_user) + target = users(:pending_user) + post approve_console_user_url(target.oid) + assert_redirected_to root_path + assert target.reload.pending? + end + end +end diff --git a/test/controllers/oauth/flows_controller_test.rb b/test/controllers/oauth/flows_controller_test.rb index 6cf55c0..954529f 100644 --- a/test/controllers/oauth/flows_controller_test.rb +++ b/test/controllers/oauth/flows_controller_test.rb @@ -45,6 +45,10 @@ def token_body(sub: "google-sub-1", email: "user@example.com", aud: CLIENT_ID, }.merge(overrides).to_json end + def sign_in(user) + post login_url, params: { email: user.email, password: "password123456" } + end + # Runs /start and returns the state extracted from the IdP redirect (the flow # cookie is set in the shared integration cookie jar as a side effect). def start_flow(slug: "google", **params) @@ -85,6 +89,15 @@ def start_flow(slug: "google", **params) assert_nil session[:user_id] end + test "start works with a pending console session" do + sign_in users(:pending_user) + + get oauth_start_url(slug: "google") + + assert_response :redirect + assert_equal "accounts.google.com", URI.parse(response.location).host + end + test "start 404s an unknown slug" do get oauth_start_url(slug: "nope") assert_response :not_found @@ -138,6 +151,22 @@ def start_flow(slug: "google", **params) assert_includes response.body, cred.oid end + test "callback works with a disabled console session" do + user = users(:member_user) + sign_in user + state = start_flow + user.update!(status: :disabled) + stub_exchange(status: 200, body: token_body) + + assert_difference -> { BrokerCredential.count }, 1 do + get oauth_callback_url(slug: "google"), params: { state: state, code: "auth-code" } + end + + assert_response :ok + assert_match "Connected", response.body + assert_equal user.id, session[:user_id] + end + test "re-consent for the same account updates the existing credential and revives a dead one" do state1 = start_flow stub_exchange(status: 200, body: token_body(email: "old@example.com")) diff --git a/test/controllers/session_oauth_controller_test.rb b/test/controllers/session_oauth_controller_test.rb new file mode 100644 index 0000000..77cabb3 --- /dev/null +++ b/test/controllers/session_oauth_controller_test.rb @@ -0,0 +1,190 @@ +require "test_helper" + +# Covers console SSO login end to end: /auth/:provider/start builds the IdP +# redirect and binds the browser; /auth/:provider/callback exchanges the code, +# provisions/links a User, and establishes the session. The IdP is faked by +# swapping the controller's exchange_client_factory for a client wrapped around an +# HTTP double returning a canned token response (mirrors the broker flow test). +class SessionOauthControllerTest < ActionDispatch::IntegrationTest + GOOGLE_CLIENT_ID = "google-login-client-id".freeze + ENV_KEYS = %w[ + IRON_CONTROL_GOOGLE_CLIENT_ID IRON_CONTROL_GOOGLE_CLIENT_SECRET IRON_CONTROL_BOOTSTRAP_ADMINS + ].freeze + + setup do + @prev_env = ENV.to_hash.slice(*ENV_KEYS) + ENV["IRON_CONTROL_GOOGLE_CLIENT_ID"] = GOOGLE_CLIENT_ID + ENV["IRON_CONTROL_GOOGLE_CLIENT_SECRET"] = "google-login-secret" + ENV["IRON_CONTROL_BOOTSTRAP_ADMINS"] = "boss@acme.example" + end + + teardown do + ENV_KEYS.each { |k| ENV.delete(k) } + @prev_env.each { |k, v| ENV[k] = v } + SessionOauthController.exchange_client_factory = -> { Broker::AuthorizationCodeClient.new } + end + + class StubHTTP + def initialize(status:, body:) + @status = status + @body = body + end + + def call(url:, form:, headers:, timeout:) + Broker::AuthorizationCodeClient::Response.new(status: @status, body: @body) + end + end + + def stub_exchange(status:, body:) + SessionOauthController.exchange_client_factory = -> { Broker::AuthorizationCodeClient.new(http: StubHTTP.new(status: status, body: body)) } + end + + def id_token(claims) + "h.#{Base64.urlsafe_encode64(claims.to_json, padding: false)}.s" + end + + def token_body(sub:, email:, email_verified: true, name: "Test User", + aud: GOOGLE_CLIENT_ID, iss: "https://accounts.google.com") + { + access_token: "AT", + id_token: id_token({ "aud" => aud, "iss" => iss, "sub" => sub, + "email" => email, "email_verified" => email_verified, "name" => name }) + }.to_json + end + + # Runs /start and returns the signed state from the IdP redirect (the flow + # cookie is set in the shared integration cookie jar as a side effect). + def start_flow(provider: "google") + get auth_start_url(provider: provider) + assert_response :redirect + URI.decode_www_form(URI.parse(response.location).query).to_h.fetch("state") + end + + def run_callback(sub:, email:, provider: "google", **token_overrides) + stub_exchange(status: 200, body: token_body(sub: sub, email: email, **token_overrides)) + state = start_flow(provider: provider) + get auth_callback_url(provider: provider), params: { code: "the-code", state: state } + end + + # --- login page ----------------------------------------------------------- + + test "the login form offers a button for each configured provider" do + get login_url + assert_response :ok + assert_select "a[href=?]", auth_start_path(provider: "google"), text: /Continue with Google/ + # Slack has no credentials configured, so it must not appear. + assert_select "a[href=?]", auth_start_path(provider: "slack"), count: 0 + end + + # --- start ---------------------------------------------------------------- + + test "start redirects to Google with login params and no offline access" do + get auth_start_url(provider: "google") + assert_response :redirect + uri = URI.parse(response.location) + assert_equal "accounts.google.com", uri.host + q = URI.decode_www_form(uri.query).to_h + assert_equal GOOGLE_CLIENT_ID, q["client_id"] + assert_equal "http://www.example.com/auth/google/callback", q["redirect_uri"] + assert_equal "code", q["response_type"] + assert_equal "openid email profile", q["scope"] + assert_equal "S256", q["code_challenge_method"] + assert_nil q["access_type"], "login must not request offline access" + assert_nil q["prompt"] + end + + test "start rejects an unconfigured provider" do + get auth_start_url(provider: "slack") # no slack creds set + assert_redirected_to login_path + assert_equal "That sign-in method is not available.", flash[:alert] + end + + # --- callback: provisioning ------------------------------------------------ + + test "callback provisions a pending user for a non-bootstrap email and signs them in" do + assert_difference -> { User.count }, 1 do + run_callback(sub: "new-sub", email: "newcomer@example.com") + end + assert_redirected_to pending_path + user = User.find_by(email: "newcomer@example.com") + assert user.pending? + assert_not user.admin? + assert_equal "Test User", user.name + assert_equal user.id, session[:user_id] + assert_equal [ [ "google", "new-sub" ] ], user.user_identities.pluck(:provider, :subject) + end + + test "callback makes a bootstrap-allowlisted email active and admin" do + run_callback(sub: "boss-sub", email: "boss@acme.example") + assert_redirected_to console_principals_path + user = User.find_by(email: "boss@acme.example") + assert user.active? + assert user.admin? + end + + test "callback links a new identity to an existing user by verified email" do + existing = users(:globex_admin) + assert_no_difference -> { User.count } do + assert_difference -> { existing.user_identities.count }, 1 do + run_callback(sub: "fresh-sub", email: existing.email, email_verified: true) + end + end + assert_redirected_to console_principals_path + assert_equal existing.id, session[:user_id] + end + + test "callback does not let an unverified email adopt an existing account" do + existing = users(:globex_admin) + assert_no_difference -> { User.count } do + assert_no_difference -> { existing.user_identities.count } do + run_callback(sub: "spoof-sub", email: existing.email, email_verified: false) + end + end + assert_redirected_to login_path + assert_nil session[:user_id] + end + + test "callback creates a pending user for an unverified, unrecognized email" do + assert_difference -> { User.count }, 1 do + run_callback(sub: "unv-sub", email: "stranger@example.com", email_verified: false) + end + user = User.find_by(email: "stranger@example.com") + assert user.pending? + assert_not user.user_identities.first.email_verified + end + + test "callback signs a returning identity into the same user" do + identity = user_identities(:acme_admin_google) + assert_no_difference -> { User.count } do + assert_no_difference -> { UserIdentity.count } do + run_callback(sub: identity.subject, email: identity.email) + end + end + assert_redirected_to console_principals_path + assert_equal identity.user_id, session[:user_id] + end + + # --- callback: rejections -------------------------------------------------- + + test "callback rejects a tampered or missing state" do + get auth_callback_url(provider: "google"), params: { code: "x", state: "not-a-real-state" } + assert_redirected_to login_path + assert_nil session[:user_id] + end + + test "callback treats an IdP error param as a cancellation" do + state = start_flow + get auth_callback_url(provider: "google"), params: { state: state, error: "access_denied" } + assert_redirected_to login_path + assert_equal "Sign in was canceled.", flash[:alert] + assert_nil session[:user_id] + end + + test "callback surfaces an exchange failure without signing in" do + stub_exchange(status: 400, body: { error: "invalid_grant" }.to_json) + state = start_flow + get auth_callback_url(provider: "google"), params: { code: "bad", state: state } + assert_redirected_to login_path + assert_nil session[:user_id] + end +end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index d0306d5..d49e1fa 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -33,4 +33,36 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to login_path assert_nil session[:user_id] end + + test "a pending user is signed in but routed to the holding page" do + pending = users(:pending_user) + post login_url, params: { email: pending.email, password: "password123456" } + assert_redirected_to pending_path + assert_equal pending.id, session[:user_id] + end + + test "a disabled user cannot sign in" do + disabled = users(:disabled_user) + post login_url, params: { email: disabled.email, password: "password123456" } + assert_response :unprocessable_entity + assert_nil session[:user_id] + end + + test "a pending user hitting a console page is bounced to the holding page" do + post login_url, params: { email: users(:pending_user).email, password: "password123456" } + get console_principals_url + assert_redirected_to pending_path + end + + test "the pending page is reachable by a pending user" do + post login_url, params: { email: users(:pending_user).email, password: "password123456" } + get pending_url + assert_response :ok + end + + test "an active user visiting the pending page is sent to the console" do + post login_url, params: { email: @operator.email, password: "password123456" } + get pending_url + assert_redirected_to console_principals_path + end end diff --git a/test/fixtures/user_identities.yml b/test/fixtures/user_identities.yml new file mode 100644 index 0000000..7e4a950 --- /dev/null +++ b/test/fixtures/user_identities.yml @@ -0,0 +1,13 @@ +acme_admin_google: + user: acme_admin + provider: google + subject: google-existing-sub + email: admin@acme.example + email_verified: true + +pending_user_slack: + user: pending_user + provider: slack + subject: slack-pending-sub + email: pending@acme.example + email_verified: true diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index c2ad29b..d53de11 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,7 +1,29 @@ acme_admin: email: admin@acme.example password_digest: <%= BCrypt::Password.create("password123456") %> + status: active + admin: true globex_admin: email: admin@globex.example password_digest: <%= BCrypt::Password.create("password123456") %> + status: active + admin: true + +member_user: + email: member@acme.example + password_digest: <%= BCrypt::Password.create("password123456") %> + status: active + admin: false + +pending_user: + email: pending@acme.example + password_digest: <%= BCrypt::Password.create("password123456") %> + status: pending + admin: false + +disabled_user: + email: disabled@acme.example + password_digest: <%= BCrypt::Password.create("password123456") %> + status: disabled + admin: false diff --git a/test/lib/broker/authorization_code_client_test.rb b/test/lib/broker/authorization_code_client_test.rb index 53629ac..f2d429d 100644 --- a/test/lib/broker/authorization_code_client_test.rb +++ b/test/lib/broker/authorization_code_client_test.rb @@ -83,6 +83,13 @@ def success_body(**overrides) assert_equal "missing_refresh_token", err.code end + test "require_refresh_token: false tolerates a missing refresh_token (login flow)" do + client, _ = client_with(status: 200, body: success_body(refresh_token: nil)) + result = client.exchange(**base_args, require_refresh_token: false) + assert_equal "AT", result.access_token + assert_nil result.refresh_token + end + test "empty access_token raises a parse error" do client, _ = client_with(status: 200, body: success_body(access_token: "")) err = assert_raises(ExchangeError) { client.exchange(**base_args) } diff --git a/test/lib/iron/bootstrap_test.rb b/test/lib/iron/bootstrap_test.rb index 9c7a212..af7bed3 100644 --- a/test/lib/iron/bootstrap_test.rb +++ b/test/lib/iron/bootstrap_test.rb @@ -23,6 +23,7 @@ class Iron::BootstrapTest < ActiveSupport::TestCase Principal.delete_all Role.delete_all ApiKey.unscoped.delete_all + UserIdentity.delete_all User.delete_all @env = ENV.to_hash.slice( "IRON_CONTROL_INITIAL_USER_EMAIL", @@ -67,6 +68,8 @@ def set_env(email: nil, password: nil, api_key: nil) user = User.find_by!(email: "boot@example.com") assert_equal user, user.authenticate("password123456") assert_equal user, ApiKey.find_by_token(VALID_TOKEN).user + assert user.active?, "the bootstrap operator must be active" + assert user.admin?, "the bootstrap operator must be an admin" end test "honors a supplied API key token" do diff --git a/test/models/user_identity_test.rb b/test/models/user_identity_test.rb new file mode 100644 index 0000000..ea9351d --- /dev/null +++ b/test/models/user_identity_test.rb @@ -0,0 +1,50 @@ +require "test_helper" + +class UserIdentityTest < ActiveSupport::TestCase + def valid_attrs(overrides = {}) + { user: users(:globex_admin), provider: "google", subject: "sub-123" }.merge(overrides) + end + + test "is valid with user, provider, and subject" do + assert UserIdentity.new(valid_attrs).valid? + end + + test "requires a user" do + identity = UserIdentity.new(valid_attrs(user: nil)) + assert_not identity.valid? + assert_includes identity.errors[:user], "must exist" + end + + test "rejects an unsupported provider" do + identity = UserIdentity.new(valid_attrs(provider: "facebook")) + assert_not identity.valid? + assert_includes identity.errors[:provider], "is not included in the list" + end + + test "subject is unique within a provider" do + existing = user_identities(:acme_admin_google) + dup = UserIdentity.new(valid_attrs(provider: existing.provider, subject: existing.subject)) + assert_not dup.valid? + assert_includes dup.errors[:subject], "has already been taken" + end + + test "the same subject may exist under a different provider" do + existing = user_identities(:acme_admin_google) + other = UserIdentity.new(valid_attrs(provider: "slack", subject: existing.subject)) + assert other.valid? + end + + test "email is normalized to lowercase and stripped" do + identity = UserIdentity.create!(valid_attrs(email: " Mixed@Case.EXAMPLE ")) + assert_equal "mixed@case.example", identity.email + end + + test "declares usid as its oid prefix" do + assert_equal "usid", UserIdentity.oid_prefix + end + + test "find_by_oid round-trips" do + identity = user_identities(:acme_admin_google) + assert_equal identity, UserIdentity.find_by_oid(identity.oid) + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 965fd0b..d149188 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -40,10 +40,9 @@ def valid_attrs(overrides = {}) assert_includes dup.errors[:email], "has already been taken" end - test "requires password on create" do + test "is valid without a password (SSO-only user)" do user = User.new(valid_attrs.except(:password)) - assert_not user.valid? - assert_includes user.errors[:password], "can't be blank" + assert user.valid? end test "rejects short passwords" do @@ -70,4 +69,100 @@ def valid_attrs(overrides = {}) user = users(:acme_admin) assert_equal user, User.find_by_oid(user.oid) end + + test "status defaults to pending" do + assert_equal "pending", User.new.status + end + + test "rejects an unknown status" do + user = User.new(valid_attrs(status: "bogus")) + assert_not user.valid? + assert_includes user.errors[:status], "is not included in the list" + end + + test "approve! activates and records the approver" do + user = User.create!(valid_attrs(email: "approve-me@example.com").except(:password)) + admin = users(:acme_admin) + user.approve!(by: admin) + assert user.reload.active? + assert_equal admin, user.approved_by + assert_not_nil user.approved_at + end + + test "destroys linked identities" do + user = User.create!(valid_attrs(email: "with-identity@example.com").except(:password)) + user.user_identities.create!(provider: "google", subject: "destroy-sub") + assert_difference -> { UserIdentity.count }, -1 do + user.destroy + end + end + + # --- link_or_provision ----------------------------------------------------- + + def identity(overrides = {}) + { subject: "sub-1", email: "newcomer@example.com", email_verified: true, name: "New Comer" }.merge(overrides) + end + + test "link_or_provision creates a pending user + identity for an unknown email" do + user = nil + assert_difference -> { User.count }, 1 do + assert_difference -> { UserIdentity.count }, 1 do + user = User.link_or_provision(provider: "google", identity: identity) + end + end + assert user.pending? + assert_not user.admin? + assert_equal "New Comer", user.name + assert_equal [ [ "google", "sub-1" ] ], user.user_identities.pluck(:provider, :subject) + end + + test "link_or_provision returns the existing user for a returning identity" do + existing = user_identities(:acme_admin_google) + user = nil + assert_no_difference [ "User.count", "UserIdentity.count" ] do + user = User.link_or_provision(provider: existing.provider, + identity: identity(subject: existing.subject, email: existing.email)) + end + assert_equal existing.user, user + end + + test "link_or_provision links a new identity to an existing user by verified email" do + target = users(:globex_admin) + user = nil + assert_no_difference -> { User.count } do + assert_difference -> { target.user_identities.count }, 1 do + user = User.link_or_provision(provider: "slack", + identity: identity(subject: "slack-new", email: target.email)) + end + end + assert_equal target, user + end + + test "link_or_provision will not let an unverified email adopt an existing account" do + target = users(:globex_admin) + assert_raises(ActiveRecord::RecordInvalid) do + User.link_or_provision(provider: "slack", + identity: identity(subject: "spoof", email: target.email, email_verified: false)) + end + end + + test "link_or_provision makes a bootstrap-allowlisted email active and admin" do + ENV["IRON_CONTROL_BOOTSTRAP_ADMINS"] = "boss@example.com" + user = User.link_or_provision(provider: "google", identity: identity(subject: "boss-sub", email: "boss@example.com")) + assert user.active? + assert user.admin? + ensure + ENV.delete("IRON_CONTROL_BOOTSTRAP_ADMINS") + end + + test "link_or_provision does not bootstrap admin from an unverified email" do + ENV["IRON_CONTROL_BOOTSTRAP_ADMINS"] = "boss@example.com" + user = User.link_or_provision(provider: "google", + identity: identity(subject: "boss-sub", email: "boss@example.com", + email_verified: false)) + assert user.pending? + assert_not user.admin? + ensure + ENV.delete("IRON_CONTROL_BOOTSTRAP_ADMINS") + end end