diff --git a/roles/web-app-odoo/files/oidc/grant_company_to_oauth_signup_users.sql b/roles/web-app-odoo/files/oidc/grant_company_to_oauth_signup_users.sql new file mode 100644 index 0000000000..7962098f22 --- /dev/null +++ b/roles/web-app-odoo/files/oidc/grant_company_to_oauth_signup_users.sql @@ -0,0 +1,16 @@ +-- Attach OAuth-signup users to the default company (id=1) so they can +-- access company-scoped data after first SSO login. Excludes admin +-- (id <= 2) and only touches users whose primary company is already +-- the default one. Idempotent via the NOT EXISTS guard. +INSERT INTO res_company_users_rel (cid, user_id) +SELECT 1, u.id +FROM res_users u +WHERE u.oauth_provider_id IS NOT NULL + AND u.active = True + AND u.id > 2 + AND u.company_id = 1 + AND NOT EXISTS ( + SELECT 1 FROM res_company_users_rel r + WHERE r.cid = 1 + AND r.user_id = u.id + ); diff --git a/roles/web-app-odoo/files/oidc/grant_company_to_oauth_template.sql b/roles/web-app-odoo/files/oidc/grant_company_to_oauth_template.sql new file mode 100644 index 0000000000..1838ff2a4f --- /dev/null +++ b/roles/web-app-odoo/files/oidc/grant_company_to_oauth_template.sql @@ -0,0 +1,12 @@ +-- Attach the "_oauth_template" user to the default company (id=1) so +-- copies of this user join that company by default. Idempotent via +-- the NOT EXISTS guard. +INSERT INTO res_company_users_rel (cid, user_id) +SELECT 1, u.id +FROM res_users u +WHERE u.login = '_oauth_template' + AND NOT EXISTS ( + SELECT 1 FROM res_company_users_rel r + WHERE r.cid = 1 + AND r.user_id = u.id + ); diff --git a/roles/web-app-odoo/files/oidc/grant_internal_user_to_oauth_signup_users.sql b/roles/web-app-odoo/files/oidc/grant_internal_user_to_oauth_signup_users.sql new file mode 100644 index 0000000000..daf47adbeb --- /dev/null +++ b/roles/web-app-odoo/files/oidc/grant_internal_user_to_oauth_signup_users.sql @@ -0,0 +1,19 @@ +-- Grant "Internal User" group to OAuth-linked users created via +-- signup. Auto-created OAuth users get portal access by default; this +-- promotes them to internal so they can reach the main Odoo +-- interface. Excludes the protected admin/system users (id <= 2) and +-- is idempotent via the NOT EXISTS guard. +INSERT INTO res_groups_users_rel (gid, uid) +SELECT imd.res_id, u.id +FROM ir_model_data imd, res_users u +WHERE imd.module = 'base' + AND imd.name = 'group_user' + AND imd.model = 'res.groups' + AND u.oauth_provider_id IS NOT NULL + AND u.active = True + AND u.id > 2 + AND NOT EXISTS ( + SELECT 1 FROM res_groups_users_rel r + WHERE r.gid = imd.res_id + AND r.uid = u.id + ); diff --git a/roles/web-app-odoo/files/oidc/grant_internal_user_to_oauth_template.sql b/roles/web-app-odoo/files/oidc/grant_internal_user_to_oauth_template.sql new file mode 100644 index 0000000000..88c24d9d2d --- /dev/null +++ b/roles/web-app-odoo/files/oidc/grant_internal_user_to_oauth_template.sql @@ -0,0 +1,15 @@ +-- Add the "_oauth_template" user to the base "Internal User" group so +-- every signup that copies from it inherits internal-user access. +-- Idempotent via the NOT EXISTS guard. +INSERT INTO res_groups_users_rel (gid, uid) +SELECT imd.res_id, u.id +FROM ir_model_data imd, res_users u +WHERE imd.module = 'base' + AND imd.name = 'group_user' + AND imd.model = 'res.groups' + AND u.login = '_oauth_template' + AND NOT EXISTS ( + SELECT 1 FROM res_groups_users_rel r + WHERE r.gid = imd.res_id + AND r.uid = u.id + ); diff --git a/roles/web-app-odoo/files/oidc/grant_internal_user_to_oauth_users_repair.sql b/roles/web-app-odoo/files/oidc/grant_internal_user_to_oauth_users_repair.sql new file mode 100644 index 0000000000..04e6d864ce --- /dev/null +++ b/roles/web-app-odoo/files/oidc/grant_internal_user_to_oauth_users_repair.sql @@ -0,0 +1,19 @@ +-- Repair fallback: grant "Internal User" to OAuth-linked users that +-- were created before `base.template_portal_user_id` was set +-- correctly. Excludes the "_oauth_template" sentinel user. New OAuth +-- signups inherit the group from the template, so this only touches +-- legacy users. +INSERT INTO res_groups_users_rel (gid, uid) +SELECT imd.res_id, u.id +FROM ir_model_data imd, res_users u +WHERE imd.module = 'base' + AND imd.name = 'group_user' + AND imd.model = 'res.groups' + AND u.oauth_provider_id IS NOT NULL + AND u.login != '_oauth_template' + AND u.active = True + AND NOT EXISTS ( + SELECT 1 FROM res_groups_users_rel r + WHERE r.gid = imd.res_id + AND r.uid = u.id + ); diff --git a/roles/web-app-odoo/files/oidc/insert_keycloak_provider.sql b/roles/web-app-odoo/files/oidc/insert_keycloak_provider.sql new file mode 100644 index 0000000000..bd069c1f0a --- /dev/null +++ b/roles/web-app-odoo/files/oidc/insert_keycloak_provider.sql @@ -0,0 +1,28 @@ +-- First install: register Keycloak as an OAuth provider. +INSERT INTO auth_oauth_provider ( + name, + enabled, + auth_endpoint, + validation_endpoint, + data_endpoint, + client_id, + scope, + css_class, + body, + sequence, + create_date, + write_date +) VALUES ( + 'Keycloak', + True, + %(auth_endpoint)s, + %(userinfo_endpoint)s, + %(userinfo_endpoint)s, + %(client_id)s, + 'openid email profile', + 'fa fa-fw fa-key', + '{"en_US": "Login with SSO"}', + 10, + NOW(), + NOW() +); diff --git a/roles/web-app-odoo/files/oidc/insert_oauth_template_partner.sql b/roles/web-app-odoo/files/oidc/insert_oauth_template_partner.sql new file mode 100644 index 0000000000..cb477b64d0 --- /dev/null +++ b/roles/web-app-odoo/files/oidc/insert_oauth_template_partner.sql @@ -0,0 +1,23 @@ +-- Create the partner row that backs the "_oauth_template" template +-- user. The new partner stays inactive (active = False) and exists +-- only to satisfy the FK from res_users.partner_id. +INSERT INTO res_partner ( + name, + email, + active, + autopost_bills, + create_uid, + write_uid, + create_date, + write_date +) VALUES ( + 'OAuth Template User', + 'oauth-template@localhost', + False, + 'ask', + 1, + 1, + NOW(), + NOW() +) +RETURNING id; diff --git a/roles/web-app-odoo/files/oidc/insert_oauth_template_user.sql b/roles/web-app-odoo/files/oidc/insert_oauth_template_user.sql new file mode 100644 index 0000000000..ab2a7ee2a5 --- /dev/null +++ b/roles/web-app-odoo/files/oidc/insert_oauth_template_user.sql @@ -0,0 +1,28 @@ +-- Create the active "_oauth_template" user. Its group memberships are +-- granted by the follow-up grant_*.sql scripts. New OAuth signups are +-- copied from this user (`base.template_portal_user_id` / +-- `auth_signup.template_user_id`). +INSERT INTO res_users ( + partner_id, + company_id, + login, + password, + active, + create_uid, + write_uid, + create_date, + write_date, + notification_type +) VALUES ( + %(partner_id)s, + 1, + '_oauth_template', + '', + True, + 1, + 1, + NOW(), + NOW(), + 'email' +) +RETURNING id; diff --git a/roles/web-app-odoo/files/oidc/select_keycloak_provider_id.sql b/roles/web-app-odoo/files/oidc/select_keycloak_provider_id.sql new file mode 100644 index 0000000000..6050f3e88b --- /dev/null +++ b/roles/web-app-odoo/files/oidc/select_keycloak_provider_id.sql @@ -0,0 +1,7 @@ +-- Look up the row id of the Keycloak OAuth provider, if registered. +-- Used to decide between INSERT (first install) and UPDATE (re-deploy), +-- and to link Odoo users to the provider. +SELECT id +FROM auth_oauth_provider +WHERE name = 'Keycloak' +LIMIT 1; diff --git a/roles/web-app-odoo/files/oidc/select_keycloak_realm_user.sql b/roles/web-app-odoo/files/oidc/select_keycloak_realm_user.sql new file mode 100644 index 0000000000..c6a53daaba --- /dev/null +++ b/roles/web-app-odoo/files/oidc/select_keycloak_realm_user.sql @@ -0,0 +1,7 @@ +-- Look up a username in the Keycloak realm DB to confirm the user +-- exists before linking the matching Odoo user to the Keycloak OAuth +-- provider. Caller passes %(realm)s and %(admin)s as named_args. +SELECT username +FROM user_entity +WHERE realm_id = (SELECT id FROM realm WHERE name = %(realm)s) + AND username = %(admin)s; diff --git a/roles/web-app-odoo/files/oidc/select_oauth_template_user.sql b/roles/web-app-odoo/files/oidc/select_oauth_template_user.sql new file mode 100644 index 0000000000..d5bb5dea9c --- /dev/null +++ b/roles/web-app-odoo/files/oidc/select_oauth_template_user.sql @@ -0,0 +1,7 @@ +-- Look up the row id of the internal "_oauth_template" user that +-- newly-created OAuth users inherit groups from. Used to skip the +-- create-template steps on re-deploy. +SELECT id +FROM res_users +WHERE login = '_oauth_template' +LIMIT 1; diff --git a/roles/web-app-odoo/files/oidc/update_activate_oauth_template.sql b/roles/web-app-odoo/files/oidc/update_activate_oauth_template.sql new file mode 100644 index 0000000000..d84c590c48 --- /dev/null +++ b/roles/web-app-odoo/files/oidc/update_activate_oauth_template.sql @@ -0,0 +1,7 @@ +-- Re-activate the "_oauth_template" user if a prior run left it +-- inactive. No-op when the user is already active. +UPDATE res_users SET + active = True, + write_date = NOW() +WHERE login = '_oauth_template' + AND active = False; diff --git a/roles/web-app-odoo/files/oidc/update_keycloak_provider.sql b/roles/web-app-odoo/files/oidc/update_keycloak_provider.sql new file mode 100644 index 0000000000..166890779d --- /dev/null +++ b/roles/web-app-odoo/files/oidc/update_keycloak_provider.sql @@ -0,0 +1,12 @@ +-- Re-deploy: refresh the Keycloak OAuth provider row in place. +UPDATE auth_oauth_provider SET + enabled = True, + auth_endpoint = %(auth_endpoint)s, + validation_endpoint = %(userinfo_endpoint)s, + data_endpoint = %(userinfo_endpoint)s, + client_id = %(client_id)s, + scope = 'openid email profile', + css_class = 'fa fa-fw fa-key', + body = '{"en_US": "Login with SSO"}', + write_date = NOW() +WHERE name = 'Keycloak'; diff --git a/roles/web-app-odoo/files/oidc/update_user_oauth_link.sql b/roles/web-app-odoo/files/oidc/update_user_oauth_link.sql new file mode 100644 index 0000000000..07f8cc218f --- /dev/null +++ b/roles/web-app-odoo/files/oidc/update_user_oauth_link.sql @@ -0,0 +1,8 @@ +-- Bind an existing Odoo res_users row to its Keycloak identity so SSO +-- recognises the user on next login. Caller passes provider_id + +-- oauth_uid + login as named_args. +UPDATE res_users SET + oauth_provider_id = %(provider_id)s, + oauth_uid = %(oauth_uid)s, + write_date = NOW() +WHERE login = %(login)s; diff --git a/roles/web-app-odoo/files/oidc/update_website_uninvited_b2c.sql b/roles/web-app-odoo/files/oidc/update_website_uninvited_b2c.sql new file mode 100644 index 0000000000..1282ded70d --- /dev/null +++ b/roles/web-app-odoo/files/oidc/update_website_uninvited_b2c.sql @@ -0,0 +1,7 @@ +-- Allow uninvited OAuth signup on every website that does not already +-- permit it. Bumps `write_date` so Odoo invalidates its caches. +UPDATE website SET + auth_signup_uninvited = 'b2c', + write_date = NOW() +WHERE auth_signup_uninvited != 'b2c' + OR auth_signup_uninvited IS NULL; diff --git a/roles/web-app-odoo/files/oidc/upsert_ir_config_parameter.sql b/roles/web-app-odoo/files/oidc/upsert_ir_config_parameter.sql new file mode 100644 index 0000000000..37286ca0cb --- /dev/null +++ b/roles/web-app-odoo/files/oidc/upsert_ir_config_parameter.sql @@ -0,0 +1,9 @@ +-- Generic upsert for `ir_config_parameter`. Caller passes %(key)s and +-- %(value)s as named_args. Used for every flag/scalar Odoo system +-- parameter (oauth_providers, invitation_scope, authorization_header, +-- web.base.url, web.base.url.freeze, ...). +INSERT INTO ir_config_parameter (key, value, create_date, write_date) +VALUES (%(key)s, %(value)s, NOW(), NOW()) +ON CONFLICT (key) DO UPDATE SET + value = %(value)s, + write_date = NOW(); diff --git a/roles/web-app-odoo/files/oidc/upsert_ir_config_parameter_template_user_id.sql b/roles/web-app-odoo/files/oidc/upsert_ir_config_parameter_template_user_id.sql new file mode 100644 index 0000000000..afc9f3f5d1 --- /dev/null +++ b/roles/web-app-odoo/files/oidc/upsert_ir_config_parameter_template_user_id.sql @@ -0,0 +1,15 @@ +-- Specialised upsert for `ir_config_parameter` rows whose value is the +-- internal template user's id. The value is computed by a subquery so +-- the template user's id never has to round-trip through Ansible. +-- Caller passes the parameter name as %(key)s +-- (base.template_portal_user_id or auth_signup.template_user_id). +INSERT INTO ir_config_parameter (key, value, create_date, write_date) +VALUES ( + %(key)s, + (SELECT id::text FROM res_users WHERE login = '_oauth_template'), + NOW(), + NOW() +) +ON CONFLICT (key) DO UPDATE SET + value = (SELECT id::text FROM res_users WHERE login = '_oauth_template'), + write_date = NOW(); diff --git a/roles/web-app-odoo/tasks/03_oidc.yml b/roles/web-app-odoo/tasks/03_oidc.yml index 4570d112de..f3a439fbf6 100644 --- a/roles/web-app-odoo/tasks/03_oidc.yml +++ b/roles/web-app-odoo/tasks/03_oidc.yml @@ -1,388 +1,192 @@ --- -# Configure OIDC (Keycloak) provider for Odoo +# Configure OIDC (Keycloak) provider for Odoo. +# +# All SQL bodies are extracted to roles/web-app-odoo/files/oidc/*.sql so +# they parse cleanly with SQL tooling. The shared connection params +# (db / user / password / host / port) are deduplicated into the +# `_odoo_db` block-vars below. -- name: "Check if Keycloak OAuth provider exists" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: "SELECT id FROM auth_oauth_provider WHERE name = 'Keycloak' LIMIT 1;" - register: oauth_check +- name: "Configure OIDC (Keycloak) for Odoo" + module_defaults: + community.postgresql.postgresql_query: + db: "{{ ODOO_DB_NAME }}" + login_user: "{{ lookup('database', application_id, 'username') }}" + login_password: "{{ lookup('database', application_id, 'password') }}" + login_host: "{{ lookup('database', application_id, 'reach_host') }}" + login_port: "{{ lookup('database', application_id, 'port') }}" + block: -- name: "Update existing Keycloak OAuth provider" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - UPDATE auth_oauth_provider SET - enabled = True, - auth_endpoint = '{{ ODOO_OIDC_AUTHORIZE_URL }}', - validation_endpoint = '{{ ODOO_OIDC_USERINFO_URL }}', - data_endpoint = '{{ ODOO_OIDC_USERINFO_URL }}', - client_id = '{{ ODOO_OIDC_CLIENT_ID }}', - scope = 'openid email profile', - css_class = 'fa fa-fw fa-key', - body = '{"en_US": "Login with SSO"}', - write_date = NOW() - WHERE name = 'Keycloak'; - when: oauth_check.query_result | length > 0 + - name: "Check if Keycloak OAuth provider exists" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/select_keycloak_provider_id.sql') }}" + register: oauth_check -- name: "Create new Keycloak OAuth provider" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - INSERT INTO auth_oauth_provider - (name, enabled, auth_endpoint, validation_endpoint, data_endpoint, client_id, scope, css_class, body, sequence, create_date, write_date) - VALUES ( - 'Keycloak', - True, - '{{ ODOO_OIDC_AUTHORIZE_URL }}', - '{{ ODOO_OIDC_USERINFO_URL }}', - '{{ ODOO_OIDC_USERINFO_URL }}', - '{{ ODOO_OIDC_CLIENT_ID }}', - 'openid email profile', - 'fa fa-fw fa-key', - '{"en_US": "Login with SSO"}', - 10, - NOW(), - NOW() - ); - when: oauth_check.query_result | length == 0 + - name: "Update existing Keycloak OAuth provider" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/update_keycloak_provider.sql') }}" + named_args: + auth_endpoint: "{{ ODOO_OIDC_AUTHORIZE_URL }}" + userinfo_endpoint: "{{ ODOO_OIDC_USERINFO_URL }}" + client_id: "{{ ODOO_OIDC_CLIENT_ID }}" + when: oauth_check.query_result | length > 0 -- name: "Enable OAuth authentication in system parameters" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - INSERT INTO ir_config_parameter (key, value, create_date, write_date) - VALUES ('auth_oauth.oauth_providers', 'True', NOW(), NOW()) - ON CONFLICT (key) DO UPDATE SET value = 'True', write_date = NOW(); + - name: "Create new Keycloak OAuth provider" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/insert_keycloak_provider.sql') }}" + named_args: + auth_endpoint: "{{ ODOO_OIDC_AUTHORIZE_URL }}" + userinfo_endpoint: "{{ ODOO_OIDC_USERINFO_URL }}" + client_id: "{{ ODOO_OIDC_CLIENT_ID }}" + when: oauth_check.query_result | length == 0 -- name: "Enable OAuth signup (allow user creation on first OAuth login)" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - INSERT INTO ir_config_parameter (key, value, create_date, write_date) - VALUES ('auth_signup.invitation_scope', 'b2c', NOW(), NOW()) - ON CONFLICT (key) DO UPDATE SET value = 'b2c', write_date = NOW(); + - name: "Enable OAuth authentication in system parameters" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/upsert_ir_config_parameter.sql') }}" + named_args: + key: "auth_oauth.oauth_providers" + value: "True" -- name: "Allow uninvited OAuth signup on website" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - UPDATE website SET auth_signup_uninvited = 'b2c', write_date = NOW() - WHERE auth_signup_uninvited != 'b2c' OR auth_signup_uninvited IS NULL; + - name: "Enable OAuth signup (allow user creation on first OAuth login)" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/upsert_ir_config_parameter.sql') }}" + named_args: + key: "auth_signup.invitation_scope" + value: "b2c" -# Create an internal user template for OAuth signup - new users inherit this user's groups -- name: "Check if OAuth template user exists" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: "SELECT id FROM res_users WHERE login = '_oauth_template' LIMIT 1;" - register: oauth_template_check + - name: "Allow uninvited OAuth signup on website" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/update_website_uninvited_b2c.sql') }}" -- name: "Create OAuth template partner" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - INSERT INTO res_partner (name, email, active, autopost_bills, create_uid, write_uid, create_date, write_date) - VALUES ('OAuth Template User', 'oauth-template@localhost', False, 'ask', 1, 1, NOW(), NOW()) - RETURNING id; - register: oauth_template_partner - when: oauth_template_check.query_result | length == 0 + # Create an internal user template for OAuth signup. New users + # inherit this user's groups via base.template_portal_user_id / + # auth_signup.template_user_id. + - name: "Check if OAuth template user exists" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/select_oauth_template_user.sql') }}" + register: oauth_template_check -- name: "Create OAuth template user" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - INSERT INTO res_users - (partner_id, company_id, login, password, active, create_uid, write_uid, create_date, write_date, notification_type) - VALUES (%(partner_id)s, 1, '_oauth_template', '', True, 1, 1, NOW(), NOW(), 'email') - RETURNING id; - named_args: - partner_id: "{{ oauth_template_partner.query_result[0].id }}" - register: oauth_template_user - when: - - oauth_template_check.query_result | length == 0 - - oauth_template_partner.query_result is defined - - oauth_template_partner.query_result | length > 0 + - name: "Create OAuth template partner" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/insert_oauth_template_partner.sql') }}" + register: oauth_template_partner + when: oauth_template_check.query_result | length == 0 -- name: "Ensure OAuth template user is active" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - UPDATE res_users SET active = True, write_date = NOW() - WHERE login = '_oauth_template' AND active = False; + - name: "Create OAuth template user" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/insert_oauth_template_user.sql') }}" + named_args: + partner_id: "{{ oauth_template_partner.query_result[0].id }}" + register: oauth_template_user + when: + - oauth_template_check.query_result | length == 0 + - oauth_template_partner.query_result is defined + - oauth_template_partner.query_result | length > 0 -- name: "Grant Internal User group to OAuth template" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - INSERT INTO res_groups_users_rel (gid, uid) - SELECT imd.res_id, u.id - FROM ir_model_data imd, res_users u - WHERE imd.module = 'base' - AND imd.name = 'group_user' - AND imd.model = 'res.groups' - AND u.login = '_oauth_template' - AND NOT EXISTS ( - SELECT 1 FROM res_groups_users_rel r - WHERE r.gid = imd.res_id AND r.uid = u.id - ); + - name: "Ensure OAuth template user is active" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/update_activate_oauth_template.sql') }}" -- name: "Grant company access to OAuth template" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - INSERT INTO res_company_users_rel (cid, user_id) - SELECT 1, u.id - FROM res_users u - WHERE u.login = '_oauth_template' - AND NOT EXISTS ( - SELECT 1 FROM res_company_users_rel r - WHERE r.cid = 1 AND r.user_id = u.id - ); + - name: "Grant Internal User group to OAuth template" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/grant_internal_user_to_oauth_template.sql') }}" -# Set the template user ID for both parameters: -# - base.template_portal_user_id: Used by _create_user_from_template() during signup -# - auth_signup.template_user_id: Used by Odoo UI settings (we set both for consistency) -- name: "Set OAuth template user for signup (base.template_portal_user_id)" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - INSERT INTO ir_config_parameter (key, value, create_date, write_date) - VALUES ('base.template_portal_user_id', - (SELECT id::text FROM res_users WHERE login = '_oauth_template'), - NOW(), NOW()) - ON CONFLICT (key) DO UPDATE SET - value = (SELECT id::text FROM res_users WHERE login = '_oauth_template'), - write_date = NOW(); + - name: "Grant company access to OAuth template" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/grant_company_to_oauth_template.sql') }}" -- name: "Set OAuth template user for signup (auth_signup.template_user_id)" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - INSERT INTO ir_config_parameter (key, value, create_date, write_date) - VALUES ('auth_signup.template_user_id', - (SELECT id::text FROM res_users WHERE login = '_oauth_template'), - NOW(), NOW()) - ON CONFLICT (key) DO UPDATE SET - value = (SELECT id::text FROM res_users WHERE login = '_oauth_template'), - write_date = NOW(); + # Set the template user id for both knobs: + # - base.template_portal_user_id is consumed by + # _create_user_from_template() during signup. + # - auth_signup.template_user_id is what the Odoo UI settings page + # displays; we set both for consistency. + - name: "Set OAuth template user for signup (base.template_portal_user_id)" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/upsert_ir_config_parameter_template_user_id.sql') }}" + named_args: + key: "base.template_portal_user_id" -- name: "Use Authorization header for OAuth token validation" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - INSERT INTO ir_config_parameter (key, value, create_date, write_date) - VALUES ('auth_oauth.authorization_header', 'True', NOW(), NOW()) - ON CONFLICT (key) DO UPDATE SET value = 'True', write_date = NOW(); + - name: "Set OAuth template user for signup (auth_signup.template_user_id)" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/upsert_ir_config_parameter_template_user_id.sql') }}" + named_args: + key: "auth_signup.template_user_id" -- name: "Set web.base.url for HTTPS redirects" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - INSERT INTO ir_config_parameter (key, value, create_date, write_date) - VALUES ('web.base.url', %(base_url)s, NOW(), NOW()) - ON CONFLICT (key) DO UPDATE SET value = %(base_url)s, write_date = NOW(); - named_args: - base_url: "{{ ODOO_URL }}" + - name: "Use Authorization header for OAuth token validation" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/upsert_ir_config_parameter.sql') }}" + named_args: + key: "auth_oauth.authorization_header" + value: "True" -- name: "Freeze web.base.url to prevent automatic detection" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - INSERT INTO ir_config_parameter (key, value, create_date, write_date) - VALUES ('web.base.url.freeze', 'True', NOW(), NOW()) - ON CONFLICT (key) DO UPDATE SET value = 'True', write_date = NOW(); + - name: "Set web.base.url for HTTPS redirects" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/upsert_ir_config_parameter.sql') }}" + named_args: + key: "web.base.url" + value: "{{ ODOO_URL }}" -# Grant Internal User group to OAuth-created users who don't have it yet. -# This is a fallback that repairs users created before base.template_portal_user_id was set correctly. -# New OAuth signups will inherit the group from the template, but this ensures existing users also have it. -- name: "Grant Internal User group to OAuth-created users" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - INSERT INTO res_groups_users_rel (gid, uid) - SELECT imd.res_id, u.id - FROM ir_model_data imd, res_users u - WHERE imd.module = 'base' - AND imd.name = 'group_user' - AND imd.model = 'res.groups' - AND u.oauth_provider_id IS NOT NULL - AND u.login != '_oauth_template' - AND u.active = True - AND NOT EXISTS ( - SELECT 1 FROM res_groups_users_rel r - WHERE r.gid = imd.res_id AND r.uid = u.id - ); + - name: "Freeze web.base.url to prevent automatic detection" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/upsert_ir_config_parameter.sql') }}" + named_args: + key: "web.base.url.freeze" + value: "True" -# Link existing Odoo users to Keycloak for SSO + # Repair fallback for users created before + # base.template_portal_user_id was set correctly. New OAuth signups + # inherit the group from the template, but legacy users do not. + - name: "Grant Internal User group to OAuth-created users" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/grant_internal_user_to_oauth_users_repair.sql') }}" -- name: "Get Keycloak OAuth provider ID in Odoo" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: "SELECT id FROM auth_oauth_provider WHERE name = 'Keycloak' LIMIT 1;" - register: keycloak_provider + # Link existing Odoo users to Keycloak for SSO. + - name: "Get Keycloak OAuth provider id in Odoo" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/select_keycloak_provider_id.sql') }}" + register: keycloak_provider -- name: "Query Keycloak DB for administrator username" - community.postgresql.postgresql_query: - db: "{{ lookup('database', 'web-app-keycloak', 'name') }}" - login_user: "{{ lookup('database', 'web-app-keycloak', 'username') }}" - login_password: "{{ lookup('database', 'web-app-keycloak', 'password') }}" - login_host: "{{ lookup('database', 'web-app-keycloak', 'reach_host') }}" - login_port: "{{ lookup('database', 'web-app-keycloak', 'port') }}" - query: > - SELECT username FROM user_entity - WHERE realm_id = (SELECT id FROM realm WHERE name = %(realm)s) - AND username = %(admin)s; - named_args: - realm: "{{ OIDC.CLIENT.REALM }}" - admin: "{{ lookup('users', 'administrator').username }}" - register: keycloak_users - when: keycloak_provider.query_result | length > 0 + - name: "Query Keycloak DB for administrator username" + community.postgresql.postgresql_query: + db: "{{ lookup('database', 'web-app-keycloak', 'name') }}" + login_user: "{{ lookup('database', 'web-app-keycloak', 'username') }}" + login_password: "{{ lookup('database', 'web-app-keycloak', 'password') }}" + login_host: "{{ lookup('database', 'web-app-keycloak', 'reach_host') }}" + login_port: "{{ lookup('database', 'web-app-keycloak', 'port') }}" + query: "{{ lookup('file', 'oidc/select_keycloak_realm_user.sql') }}" + named_args: + realm: "{{ OIDC.CLIENT.REALM }}" + admin: "{{ lookup('users', 'administrator').username }}" + register: keycloak_users + when: keycloak_provider.query_result | length > 0 -- name: "Link administrator user to Keycloak in Odoo" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - UPDATE res_users SET - oauth_provider_id = %(provider_id)s, - oauth_uid = %(oauth_uid)s, - write_date = NOW() - WHERE login = %(login)s; - named_args: - provider_id: "{{ keycloak_provider.query_result[0].id }}" - oauth_uid: "{{ (keycloak_users.query_result | selectattr('username', 'equalto', lookup('users', 'administrator').username) | first).username }}" - login: "{{ lookup('users', 'administrator').username }}" - when: - - keycloak_provider.query_result | length > 0 - - keycloak_users.query_result | selectattr('username', 'equalto', lookup('users', 'administrator').username) | list | length > 0 + - name: "Link administrator user to Keycloak in Odoo" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/update_user_oauth_link.sql') }}" + named_args: + provider_id: "{{ keycloak_provider.query_result[0].id }}" + oauth_uid: "{{ (keycloak_users.query_result | selectattr('username', 'equalto', lookup('users', 'administrator').username) | first).username }}" + login: "{{ lookup('users', 'administrator').username }}" + when: + - keycloak_provider.query_result | length > 0 + - keycloak_users.query_result | selectattr('username', 'equalto', lookup('users', 'administrator').username) | list | length > 0 -# Auto-created OAuth users get portal access by default. Grant internal user access -# to all OAuth-linked users so they can access the main Odoo interface. + # Auto-created OAuth users get portal access by default. Promote + # them to internal-user + default-company so they can reach the + # main Odoo interface. + - name: "Grant internal user access to OAuth-created users" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/grant_internal_user_to_oauth_signup_users.sql') }}" + when: keycloak_provider.query_result | length > 0 -- name: "Grant internal user access to OAuth-created users" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - INSERT INTO res_groups_users_rel (gid, uid) - SELECT imd.res_id, u.id - FROM ir_model_data imd, res_users u - WHERE imd.module = 'base' - AND imd.name = 'group_user' - AND imd.model = 'res.groups' - AND u.oauth_provider_id IS NOT NULL - AND u.active = True - AND u.id > 2 - AND NOT EXISTS ( - SELECT 1 FROM res_groups_users_rel r - WHERE r.gid = imd.res_id AND r.uid = u.id - ); - when: keycloak_provider.query_result | length > 0 + - name: "Grant company access to OAuth-created users" + community.postgresql.postgresql_query: + query: "{{ lookup('file', 'oidc/grant_company_to_oauth_signup_users.sql') }}" + when: keycloak_provider.query_result | length > 0 -- name: "Grant company access to OAuth-created users" - community.postgresql.postgresql_query: - db: "{{ ODOO_DB_NAME }}" - login_user: "{{ lookup('database', application_id, 'username') }}" - login_password: "{{ lookup('database', application_id, 'password') }}" - login_host: "{{ lookup('database', application_id, 'reach_host') }}" - login_port: "{{ lookup('database', application_id, 'port') }}" - query: > - INSERT INTO res_company_users_rel (cid, user_id) - SELECT 1, u.id - FROM res_users u - WHERE u.oauth_provider_id IS NOT NULL - AND u.active = True - AND u.id > 2 - AND u.company_id = 1 - AND NOT EXISTS ( - SELECT 1 FROM res_company_users_rel r - WHERE r.cid = 1 AND r.user_id = u.id - ); - when: keycloak_provider.query_result | length > 0 - -# Note: Non-admin users (like biber) are auto-created via OAuth signup when they first -# SSO login. The auth_signup.invitation_scope = 'b2c' setting enables this. -# Users are defined in inventories/development/default.yml and created in LDAP by svc-db-openldap, -# then federated to Keycloak for SSO authentication. -# The tasks above ensure OAuth-created users get internal access and company membership. +# Note: Non-admin users (like biber) are auto-created via OAuth signup +# when they first SSO login. The auth_signup.invitation_scope = 'b2c' +# setting enables this. Users are defined in +# inventories/development/default.yml and created in LDAP by +# svc-db-openldap, then federated to Keycloak for SSO authentication. +# The tasks above ensure OAuth-created users get internal access and +# company membership.