diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b78a9257..3381914d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ This file contains all the latest changes and updates to Postal. +## [Unreleased] + +### Features + +* **oidc:** add optional auto-creation of users during OIDC login when enabled via `OIDC_AUTO_CREATE_USERS` +* **oidc:** optionally create a default organization for auto-provisioned users via `OIDC_AUTO_CREATE_ORGANIZATION` + ## [3.3.4](https://github.com/postalserver/postal/compare/3.3.3...3.3.4) (2024-06-20) diff --git a/app/models/user.rb b/app/models/user.rb index 8bf48aac1..b99fbd2d6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -116,15 +116,23 @@ def find_from_oidc(auth, logger: nil) end end - # now, if we still don't have a user, we're not going to create one so we'll just - # return nil (we might auto create users in the future but not right now) - return if user.nil? + # If we still don't have a user and auto creation is enabled, create one from the OIDC details. + if user.nil? + if config.auto_create_users? + user = auto_create_user_from_oidc(uid, config, oidc_name, oidc_email_address, logger) + else + logger&.debug "OIDC auto user creation disabled; not creating user for #{oidc_email_address || uid}" + end + return if user.nil? + end # otherwise, let's update our user as appropriate user.oidc_uid = uid user.oidc_issuer = config.issuer user.email_address = oidc_email_address if oidc_email_address.present? - user.first_name, user.last_name = oidc_name.split(/\s+/, 2) if oidc_name.present? + if oidc_name.present? + user.first_name, user.last_name = derive_user_names_from_oidc(oidc_name, user.email_address) + end user.password = nil user.save! @@ -132,6 +140,59 @@ def find_from_oidc(auth, logger: nil) user end + private + + def auto_create_user_from_oidc(uid, config, oidc_name, oidc_email_address, logger) + unless oidc_email_address.present? + logger&.warn "OIDC auto user creation failed for UID #{uid}: no e-mail address provided" + return nil + end + + first_name, last_name = derive_user_names_from_oidc(oidc_name, oidc_email_address) + user = new( + email_address: oidc_email_address, + first_name: first_name, + last_name: last_name + ) + user.oidc_uid = uid + user.oidc_issuer = config.issuer + user.password = nil + user.save! + logger&.info "OIDC auto user creation succeeded for #{oidc_email_address} (user ID: #{user.id}) with Firstname: #{first_name}, Lastname: #{last_name}" + auto_create_organization_for(user, config, logger) if config.auto_create_organization? + user + rescue ActiveRecord::RecordInvalid => e + logger&.error "OIDC auto user creation failed for #{oidc_email_address}: #{e.message}" + nil + end + + def derive_user_names_from_oidc(oidc_name, oidc_email_address) + raw_name = oidc_name.to_s.strip + if raw_name.present? + first_name, last_name = raw_name.split(/\s+/, 2) + else + local_part = oidc_email_address.to_s.split("@", 2).first.to_s + fallback_name = local_part.tr("._-", " ").strip + first_name, last_name = fallback_name.split(/\s+/, 2) + end + + first_name = first_name.presence || "OIDC" + last_name = last_name.presence || first_name + [first_name, last_name] + end + + def auto_create_organization_for(user, config, logger) + organization_name = config.auto_created_organization_name.presence || "My organization" + organization = Organization.new(name: organization_name, owner: user) + organization.save! + organization.organization_users.create!(user: user, admin: true, all_servers: true) + logger&.info "OIDC auto organization creation succeeded for user #{user.id} (organization ID: #{organization.id})" + organization + rescue ActiveRecord::RecordInvalid => e + logger&.error "OIDC auto organization creation failed for user #{user.id}: #{e.message}" + nil + end + end end diff --git a/doc/config/environment-variables.md b/doc/config/environment-variables.md index 940424e04..2abc790bd 100644 --- a/doc/config/environment-variables.md +++ b/doc/config/environment-variables.md @@ -101,6 +101,9 @@ This document contains all the environment variables which are available for thi | `MIGRATION_WAITER_SLEEP_TIME` | Integer | The number of seconds to wait between each migration check | 2 | | `OIDC_ENABLED` | Boolean | Enable OIDC authentication | false | | `OIDC_LOCAL_AUTHENTICATION_ENABLED` | Boolean | When enabled, users with passwords will still be able to login locally. If disable, only OpenID Connect will be available. | true | +| `OIDC_AUTO_CREATE_USERS` | Boolean | Automatically create a user record when a valid OIDC login is received but no matching user exists | false | +| `OIDC_AUTO_CREATE_ORGANIZATION` | Boolean | Automatically create a default organization for auto-created OIDC users | false | +| `OIDC_AUTO_CREATED_ORGANIZATION_NAME` | String | Name to assign to automatically created organizations | My organization | | `OIDC_NAME` | String | The name of the OIDC provider as shown in the UI | OIDC Provider | | `OIDC_ISSUER` | String | The OIDC issuer URL | | | `OIDC_IDENTIFIER` | String | The client ID for OIDC | | diff --git a/doc/config/yaml.yml b/doc/config/yaml.yml index f3a735a9f..c24bf244d 100644 --- a/doc/config/yaml.yml +++ b/doc/config/yaml.yml @@ -229,6 +229,12 @@ oidc: enabled: false # When enabled, users with passwords will still be able to login locally. If disable, only OpenID Connect will be available. local_authentication_enabled: true + # Automatically create a user record when a valid OIDC login is received but no user exists + auto_create_users: false + # Automatically create a default organization for auto-created users + auto_create_organization: false + # The name to use for automatically created organizations + auto_created_organization_name: My organization # The name of the OIDC provider as shown in the UI name: OIDC Provider # The OIDC issuer URL diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 64ec0acfb..000000000 --- a/docker-compose.yml +++ /dev/null @@ -1,28 +0,0 @@ -services: - postal: - image: ${POSTAL_IMAGE} - depends_on: - - mariadb - entrypoint: ["/docker-entrypoint.sh"] - volumes: - - "./docker/ci-config:/config" - environment: - POSTAL_SIGNING_KEY_PATH: /config/signing.key - MAIN_DB_HOST: mariadb - MAIN_DB_USERNAME: root - MESSAGE_DB_HOST: mariadb - MESSAGE_DB_USERNAME: root - LOGGING_ENABLED: "false" - RAILS_ENVIRONMENT: test - RAILS_LOG_ENABLED: "false" - WAIT_FOR_TIMEOUT: 90 - WAIT_FOR_TARGETS: |- - mariadb:3306 - - mariadb: - image: mariadb - restart: always - environment: - MARIADB_DATABASE: postal - MARIADB_ALLOW_EMPTY_PASSWORD: 'yes' - MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 'yes' diff --git a/lib/postal/config_schema.rb b/lib/postal/config_schema.rb index e3c6415d5..508e276bc 100644 --- a/lib/postal/config_schema.rb +++ b/lib/postal/config_schema.rb @@ -530,6 +530,21 @@ module Postal default true end + boolean :auto_create_users do + description "Automatically create a user record when a valid OIDC login is received but no matching user exists" + default false + end + + boolean :auto_create_organization do + description "Automatically create a default organization for auto-created OIDC users" + default false + end + + string :auto_created_organization_name do + description "The name to use for automatically created organizations" + default "My organization" + end + string :name do description "The name of the OIDC provider as shown in the UI" default "OIDC Provider" diff --git a/spec/models/user/oidc_spec.rb b/spec/models/user/oidc_spec.rb index a03668cbb..bfc55bca8 100644 --- a/spec/models/user/oidc_spec.rb +++ b/spec/models/user/oidc_spec.rb @@ -24,6 +24,11 @@ allow(Postal::Config.oidc).to receive(:enabled?).and_return(true) allow(Postal::Config.oidc).to receive(:issuer).and_return(issuer) allow(Postal::Config.oidc).to receive(:email_address_field).and_return("email") + allow(Postal::Config.oidc).to receive(:uid_field).and_return("sub") + allow(Postal::Config.oidc).to receive(:name_field).and_return("name") + allow(Postal::Config.oidc).to receive(:auto_create_users?).and_return(false) + allow(Postal::Config.oidc).to receive(:auto_create_organization?).and_return(false) + allow(Postal::Config.oidc).to receive(:auto_created_organization_name).and_return("My organization") end let(:uid) { "abcdef" } @@ -53,6 +58,17 @@ expect(@existing_user.email_address).to eq "test@example.com" end + context "when the OIDC name contains a single word" do + let(:oidc_name) { "johnny" } + + it "duplicates the value for the last name" do + result + @existing_user.reload + expect(@existing_user.first_name).to eq "johnny" + expect(@existing_user.last_name).to eq "johnny" + end + end + it "logs" do result expect(logger).to have_logged(/found user with UID abcdef/i) @@ -100,14 +116,73 @@ end context "when there is no user which matches the email address" do - it "returns nil" do - expect(result).to be_nil + context "and auto creation is disabled" do + it "returns nil" do + expect(result).to be_nil + end + + it "logs" do + result + expect(logger).to have_logged(/no user with UID abcdef/) + expect(logger).to have_logged(/no user with e-mail address/) + end end - it "logs" do - result - expect(logger).to have_logged(/no user with UID abcdef/) - expect(logger).to have_logged(/no user with e-mail address/) + context "and auto creation is enabled" do + before do + allow(Postal::Config.oidc).to receive(:auto_create_users?).and_return(true) + end + + it "creates a new user" do + expect { result }.to change(User, :count).by(1) + expect(result.email_address).to eq oidc_email + expect(result.oidc_uid).to eq uid + expect(result.oidc_issuer).to eq issuer + expect(result.first_name).to eq "John" + expect(result.last_name).to eq "Smith" + end + + it "logs the creation" do + result + expect(logger).to have_logged(/OIDC auto user creation succeeded/i) + end + + context "when no name is provided" do + let(:oidc_name) { nil } + + it "derives a name from the e-mail address" do + expect(result.first_name).to eq "test" + expect(result.last_name).to eq "test" + end + end + + context "when no e-mail is provided" do + let(:oidc_email) { nil } + + it "cannot create a user" do + expect(result).to be_nil + expect(logger).to have_logged(/no e-mail address provided/) + end + end + + context "when organization auto creation is enabled" do + let(:organization_name) { "My organization" } + + before do + allow(Postal::Config.oidc).to receive(:auto_create_organization?).and_return(true) + allow(Postal::Config.oidc).to receive(:auto_created_organization_name).and_return(organization_name) + end + + it "creates an organization owned by the new user" do + expect { result }.to change(Organization, :count).by(1) + organization = Organization.last + expect(organization.name).to eq organization_name + expect(organization.owner).to eq result + expect(result.organizations).to include(organization) + expect(organization.organization_users.first.user).to eq result + expect(organization.organization_users.first.admin).to be true + end + end end end end